mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
feat(pi): fill thinkingLevelMap of a fetched model
Without a thinking map, Pi shows off..high for a custom model and hides xhigh and max, even when the upstream supports them. When a fetched model is picked and has no map yet, fill one: - Use the reviewed map of the Pi preset at the same endpoint, but only under the same API and when the compat it relies on does not conflict with what the user wrote. Missing compat keys are added to the model, and for openai-completions the thinking format and reasoning_effort support that Pi detects must match the preset's. - Otherwise, when Pi sends levels verbatim as reasoning_effort (openai-responses, or openai-completions detected as the OpenAI thinking format), derive the map from models.dev effort levels using Pi's own getEffortThinkingLevelMap rule. A model with its own baseUrl is now looked up by that address. Update the Pi design docs accordingly.
This commit is contained in:
1 parent
f11ac73366
commit
882344b698
12 files changed
+484
-30
No files matched your search
@@ -153,13 +153,13 @@ CC Switch 不设置、展示或标记 Pi 的当前供应商和模型。
|
||||
- 来源依次为:同一请求地址的 Pi 预设模型、models.dev 中按请求地址认出的同一家供应商、models.dev 中该模型的原厂条目。不引用其他应用的预设。
|
||||
- 必须先确定供应商再查模型,不得按模型名跨供应商投票或取最新值:同一模型在不同供应商处的上下文长度可能相差数倍。早期实现按名称投票,填出的值不可靠,曾因此整体移除。认不出供应商、原厂有歧义或查不到时保持默认值,也不根据模型前缀或相似版本猜测。
|
||||
- 只补空字段:上下文长度和最大输出 Token 只在为空时填写;扩展思考和图片输入只从关闭改为开启,不替用户关闭。
|
||||
- 不自动生成 `thinkingLevelMap`:它是 Pi 自己的档位映射,外部目录给不出。
|
||||
- `thinkingLevelMap` 只在模型还没有映射、且已支持扩展思考时补:同一请求地址的 Pi 预设里人工核对过的映射优先,但映射只在预设的协议和 compat 下成立,所以要求协议相同,并把预设依赖、模型里还没写的 compat 一并补上;用户已写的 compat 与预设冲突时不套用预设映射;`openai-completions` 还要求补完 compat 后,Pi 按地址和 compat 探测出的思考格式及是否发送 `reasoning_effort` 与预设一致(预设可能没写 compat、依赖 Pi 的默认探测)。模型单独写了 `baseUrl` 时,按模型实际请求的地址查找预设和 models.dev。否则仅当 Pi 会把档位原样作为 `reasoning_effort` 发给上游时(`openai-responses`,或 Pi 按供应商 ID 和地址探测为 OpenAI 思考格式且支持 `reasoning_effort` 的 `openai-completions`,compat 里写明的值优先),按 Pi 官方从 models.dev 生成内置模型目录的同一规则,用按上述来源查到的 effort 档位生成(中转站认不出时取原厂条目,绝大多数中转与原厂档位一致):列出的档位发同名值,没列出的写 `null`,`off` 在列出 `none` 时发 `"none"`。Anthropic 等需要额外 compat 的协议不生成。
|
||||
- 补全后用一条提示说明来源并请用户核对。不显示“自动值”“已覆盖自动值”或“恢复自动值”等持久状态,补进来的值与手填值没有区别。
|
||||
- models.dev 不可用时只用预设值,不报错。
|
||||
|
||||
`thinkingLevelMap` 排在上下文长度和最大输出 Token 之后,作为最后一项模型能力提供渐进式编辑。它默认折叠并保持整行宽度、左侧对齐;展开后用七行轻量列表直接展示字符串、Pi 默认和不可用三种状态,点击某一行才在表单中部打开该档位的编辑浮层。关闭“支持扩展思考”时整段隐藏,但不删除已有映射。
|
||||
|
||||
所有推理预设模型都显式提供 `thinkingLevelMap`;已确认的接口组合写入对应映射,其余预设使用 `{}` 明确沿用 Pi 默认行为。自定义模型不自动生成映射,用户可以手动配置。模型级 `api`、`baseUrl`、`cost` 等少见字段不做结构化入口,但编辑保存必须保留。
|
||||
所有推理预设模型都显式提供 `thinkingLevelMap`;已确认的接口组合写入对应映射,其余预设使用 `{}` 明确沿用 Pi 默认行为。自定义模型只在从上游列表选中时按上述规则补映射,手动输入不补,用户可以随时修改。模型级 `api`、`baseUrl`、`cost` 等少见字段不做结构化入口,但编辑保存必须保留。
|
||||
|
||||
### 4.6 请求头
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Pi 模型能力与思考档位需求
|
||||
|
||||
> 状态:已实现并完成验收
|
||||
> 原则:预设完整可靠,自定义配置不猜测;运行时不依赖外部模型目录。
|
||||
> 原则:预设完整可靠,自定义配置只采用能确认来源的值;外部目录不可用时不影响使用。
|
||||
|
||||
## 1. 当前版本边界
|
||||
|
||||
模型能力分为两条互不混用的路径:
|
||||
|
||||
- Pi 供应商预设由 CC Switch 维护完整模型配置,选择后可以直接保存。
|
||||
- 自定义供应商从“获取模型列表”选中模型时,按 [Pi 前端设计指南 4.5](./pi-frontend-uiux-guidelines-zh.md) 的规则从同地址预设和 models.dev 补全 `reasoning`、`input`、`contextWindow`、`maxTokens`;`thinkingLevelMap` 不自动生成。
|
||||
- 自定义供应商从“获取模型列表”选中模型时,按 [Pi 前端设计指南 4.5](./pi-frontend-uiux-guidelines-zh.md) 的规则从同地址预设和 models.dev 补全 `reasoning`、`input`、`contextWindow`、`maxTokens`,并在协议允许时补 `thinkingLevelMap`(规则见第 4 节)。
|
||||
|
||||
预设里的思考映射仍只在开发时人工核对后写入,运行时不从外部服务下载映射。
|
||||
预设里的思考映射仍只在开发时人工核对后写入。
|
||||
|
||||
这项功能不修改数据库 Schema,不参与显式供应商同步,也不管理 Pi 的默认供应商、默认模型、`auth.json`、路由、故障转移或插件。
|
||||
|
||||
@@ -72,7 +72,7 @@ type PiThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
|
||||
- 尚无可靠专用映射的组合使用 `{}`,明确交给 Pi 原生行为。
|
||||
- Anthropic 自适应思考等需要额外 Pi 原生兼容字段的预设,应同时写入必要的 `compat`,不能只让界面显示档位。
|
||||
|
||||
映射只服务预设构建。自定义供应商即使输入相同模型 ID,也不会自动套用预设映射。
|
||||
预设映射只在请求地址和协议都与预设相同时套用到自定义模型,并连同它依赖的 compat 一起补上(缺的才补;与用户已写的值冲突则不套用)。`openai-completions` 还要核对补完 compat 后的实际思考行为与预设一致,避免预设依赖 Pi 默认探测、用户又改了 compat 的情况。模型级 `baseUrl` 优先于供应商地址。其他供应商即使输入相同模型 ID,也不会套用预设映射。
|
||||
|
||||
## 4. 自定义模型
|
||||
|
||||
@@ -80,7 +80,8 @@ type PiThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
|
||||
|
||||
- 模型 ID 写入表单。
|
||||
- 显示名称在仍为空时同步为模型 ID,用户可以修改。
|
||||
- 手动输入的模型 ID 不补全任何能力。从上游模型列表选中时,按设计指南 4.5 补全 `reasoning`、`input`、`contextWindow`、`maxTokens`,只补空字段、只往“支持”方向补,并提示用户核对;`thinkingLevelMap` 不自动填写。
|
||||
- 手动输入的模型 ID 不补全任何能力。从上游模型列表选中时,按设计指南 4.5 补全 `reasoning`、`input`、`contextWindow`、`maxTokens`,只补空字段、只往“支持”方向补,并提示用户核对。
|
||||
- 选中的模型还没有 `thinkingLevelMap` 且支持扩展思考时补映射:同地址、同协议预设的映射优先,并补上它依赖的 compat;否则只在 Pi 会把档位原样作为 `reasoning_effort` 发出时(`openai-responses`,或探测为 OpenAI 思考格式且支持 `reasoning_effort` 的 `openai-completions`),按 Pi 官方 `getEffortThinkingLevelMap` 的规则,用 models.dev 的 effort 档位生成(先找按地址认出的供应商,认不出时取原厂条目)。DeepSeek、z.ai、OpenRouter、Moonshot 等有自己思考格式的地址,以及 Anthropic 等协议不生成。
|
||||
- 上下文长度和最大输出 Token 必须是正数才能保存。
|
||||
- “支持扩展思考”和“支持图片输入”默认分别为关闭和仅文本,用户可以随时修改。
|
||||
- 开启扩展思考后才显示思考档位编辑器。
|
||||
@@ -104,7 +105,7 @@ type PiThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
|
||||
- 每个预设模型都有完整的名称、思考能力、输入类型、上下文长度和最大输出 Token。
|
||||
- 每个推理预设模型都显式拥有合法 `thinkingLevelMap`。
|
||||
- 所有 GPT-5.6 Sol 预设均使用 `272000` 上下文。
|
||||
- 手动输入模型 ID 不补全能力;从上游列表选中时只补空字段,不生成思考映射,查不到时保持默认值。
|
||||
- 手动输入模型 ID 不补全能力;从上游列表选中时只补空字段,已有思考映射不改,只在上述协议下按 Pi 官方规则生成映射,查不到时保持默认值。
|
||||
- 自定义模型缺少名称、上下文长度或最大输出 Token 时不能保存,并聚焦错误字段。
|
||||
- JSON 往返不丢未知字段或改变思考映射语义。
|
||||
|
||||
|
||||
@@ -67,7 +67,13 @@ import { useLatestRef } from "@/hooks/useLatestRef";
|
||||
import { useModelMetadataFill } from "@/hooks/useModelMetadataFill";
|
||||
import type { KnownModelMetadata } from "@/lib/modelMetadata";
|
||||
import { piPresetModelSources } from "@/config/presetModelMetadata";
|
||||
import { metadataFilledAnything } from "./modelMetadataFill";
|
||||
import {
|
||||
metadataFilledAnything,
|
||||
type PiProviderProtocol,
|
||||
piPresetThinkingFor,
|
||||
piSendsReasoningEffort,
|
||||
piThinkingLevelMapFromEfforts,
|
||||
} from "./modelMetadataFill";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import { translatePiProviderMutationError } from "@/utils/errorUtils";
|
||||
@@ -292,13 +298,31 @@ function modelDraft(
|
||||
};
|
||||
}
|
||||
|
||||
/** 这一行实际使用的协议:模型级的 `api`、`baseUrl`、`compat` 覆盖供应商级。 */
|
||||
function piModelProtocol(
|
||||
model: PiModelDraft,
|
||||
provider: PiProviderProtocol,
|
||||
): PiProviderProtocol {
|
||||
const { passthrough } = model;
|
||||
return {
|
||||
...provider,
|
||||
api: optionalText(passthrough.api) || provider.api,
|
||||
baseUrl: optionalText(passthrough.baseUrl) || provider.baseUrl,
|
||||
compat: { ...provider.compat, ...asObject(passthrough.compat) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中拉取到的模型后补上已知参数:数字只补空的,推理和图片输入只往「支持」补。
|
||||
* `thinkingLevelMap` 不补:它是 Pi 自己的档位映射,外部目录给不出。
|
||||
* `thinkingLevelMap` 只在模型还没有映射且支持推理时补:同地址 Pi 预设核对过的
|
||||
* 映射优先,但只在协议相同、且预设依赖的 compat 不与用户已写的值冲突时采用,
|
||||
* 缺的 compat 一并补进模型;否则仅当 Pi 会把档位原样作为 `reasoning_effort` 发出
|
||||
* 时,按 Pi 官方规则从 effort 档位生成。
|
||||
*/
|
||||
function fillPiModelDraft(
|
||||
model: PiModelDraft,
|
||||
metadata: KnownModelMetadata,
|
||||
provider: PiProviderProtocol,
|
||||
): PiModelDraft {
|
||||
const next = { ...model };
|
||||
if (!model.contextWindow.trim() && metadata.contextWindow) {
|
||||
@@ -320,6 +344,28 @@ function fillPiModelDraft(
|
||||
next.input = withImageInput(model.input, true);
|
||||
next.hasInput = true;
|
||||
}
|
||||
if (!model.hasThinkingLevelMap && next.reasoning) {
|
||||
const effective = piModelProtocol(model, provider);
|
||||
const preset = piPresetThinkingFor(metadata.piThinking, effective);
|
||||
const map =
|
||||
preset?.map ??
|
||||
(piSendsReasoningEffort(effective)
|
||||
? piThinkingLevelMapFromEfforts(metadata.reasoningEfforts)
|
||||
: undefined);
|
||||
if (map) {
|
||||
next.thinkingLevelMap = map;
|
||||
next.hasThinkingLevelMap = true;
|
||||
if (preset && Object.keys(preset.missingCompat).length > 0) {
|
||||
next.passthrough = {
|
||||
...model.passthrough,
|
||||
compat: {
|
||||
...asObject(model.passthrough.compat),
|
||||
...preset.missingCompat,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -897,24 +943,39 @@ export function PiProviderForm({
|
||||
};
|
||||
|
||||
const commitModelsRef = useLatestRef(commitModels);
|
||||
const protocolRef = useLatestRef<PiProviderProtocol>({
|
||||
api,
|
||||
baseUrl,
|
||||
providerId: providerKey,
|
||||
compat: providerCompat,
|
||||
});
|
||||
const fillModelMetadata = useModelMetadataFill({
|
||||
baseUrl,
|
||||
presets: piPresetModelSources,
|
||||
prefetch: fetchedModels.length > 0,
|
||||
});
|
||||
|
||||
// 选中拉取到的模型:改 ID,再补上它已知的窗口、输出上限、推理和图片输入。
|
||||
// 选中拉取到的模型:改 ID,再补上它已知的窗口、输出上限、推理、图片输入和思考档位。
|
||||
const selectFetchedModelId = (key: string, id: string) => {
|
||||
const row = modelsRef.current.find((model) => model.key === key);
|
||||
changeModelId(key, id);
|
||||
fillModelMetadata(id, (metadata) => {
|
||||
const current = modelsRef.current.find((model) => model.key === key);
|
||||
if (current?.id !== id) return false;
|
||||
const filled = fillPiModelDraft(current, metadata);
|
||||
if (!metadataFilledAnything(current, filled)) return false;
|
||||
return commitModelsRef.current(
|
||||
modelsRef.current.map((model) => (model.key === key ? filled : model)),
|
||||
);
|
||||
});
|
||||
// 模型单独指向别的地址时,按它实际请求的地址查参数。
|
||||
const rowBaseUrl = row && piModelProtocol(row, protocolRef.current).baseUrl;
|
||||
fillModelMetadata(
|
||||
id,
|
||||
(metadata) => {
|
||||
const current = modelsRef.current.find((model) => model.key === key);
|
||||
if (current?.id !== id) return false;
|
||||
const filled = fillPiModelDraft(current, metadata, protocolRef.current);
|
||||
if (!metadataFilledAnything(current, filled)) return false;
|
||||
return commitModelsRef.current(
|
||||
modelsRef.current.map((model) =>
|
||||
model.key === key ? filled : model,
|
||||
),
|
||||
);
|
||||
},
|
||||
rowBaseUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const updateThinkingLevelMap = (
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
import type { KnownModelMetadata } from "@/lib/modelMetadata";
|
||||
import type { CodexCatalogModel, OpenClawModel, OpenCodeModel } from "@/types";
|
||||
import type { HermesModel } from "@/config/hermesProviderPresets";
|
||||
import {
|
||||
PI_THINKING_LEVELS,
|
||||
type PiThinkingLevelMap,
|
||||
} from "@/config/piThinkingProfiles";
|
||||
|
||||
/** 补全前后是否不同:没补上任何字段时不提示用户。 */
|
||||
export const metadataFilledAnything = (before: unknown, after: unknown) =>
|
||||
@@ -104,3 +108,135 @@ export function fillOpenCodeModel(
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const PI_EFFORT_LEVELS = PI_THINKING_LEVELS.filter((level) => level !== "off");
|
||||
|
||||
/**
|
||||
* 把 effort 档位转成 Pi 的 thinkingLevelMap,规则照搬 Pi 官方从 models.dev 生成
|
||||
* 内置模型目录的 `getEffortThinkingLevelMap`:列出的档位发同名值,没列出的写
|
||||
* `null` 隐藏;`off` 在列出 `none` 时发 `"none"`,否则同样隐藏。一个 Pi 档位都
|
||||
* 对不上时不生成。
|
||||
*/
|
||||
export function piThinkingLevelMapFromEfforts(
|
||||
efforts: readonly string[] | undefined,
|
||||
): PiThinkingLevelMap | undefined {
|
||||
const supported = new Set(efforts);
|
||||
if (
|
||||
!PI_EFFORT_LEVELS.some((level) => supported.has(level)) &&
|
||||
!supported.has("none")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const map: PiThinkingLevelMap = {
|
||||
off: supported.has("none") ? "none" : null,
|
||||
};
|
||||
for (const level of PI_EFFORT_LEVELS) {
|
||||
map[level] = supported.has(level) ? level : null;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export interface PiProviderProtocol {
|
||||
api: string;
|
||||
baseUrl: string;
|
||||
providerId: string;
|
||||
compat: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi 运行时对 Chat Completions 的思考行为探测(`detectCompat`):按供应商 ID 和
|
||||
* 地址认出思考格式、是否发送 `reasoning_effort`,compat 里写明的值优先。
|
||||
*/
|
||||
function piCompletionsThinking({
|
||||
baseUrl,
|
||||
providerId,
|
||||
compat,
|
||||
}: Omit<PiProviderProtocol, "api">): { format: string; effort: boolean } {
|
||||
const url = baseUrl.toLowerCase();
|
||||
const is = (ids: string[], hosts: string[]) =>
|
||||
ids.includes(providerId) || hosts.some((host) => url.includes(host));
|
||||
const isDeepSeek = is(["deepseek"], ["deepseek.com"]);
|
||||
const isZai = is(["zai", "zai-coding-cn"], ["api.z.ai", "open.bigmodel.cn"]);
|
||||
const isTogether = is(["together"], ["api.together.ai", "api.together.xyz"]);
|
||||
const isAntLing = is(["ant-ling"], ["api.ant-ling.com"]);
|
||||
const isOpenRouter = is(["openrouter"], ["openrouter.ai"]);
|
||||
const detectedFormat = isDeepSeek
|
||||
? "deepseek"
|
||||
: isZai
|
||||
? "zai"
|
||||
: isTogether
|
||||
? "together"
|
||||
: isAntLing
|
||||
? "ant-ling"
|
||||
: isOpenRouter
|
||||
? "openrouter"
|
||||
: "openai";
|
||||
const detectedEffort =
|
||||
!is(["xai"], ["api.x.ai"]) &&
|
||||
!isZai &&
|
||||
!is(["moonshotai", "moonshotai-cn"], ["api.moonshot."]) &&
|
||||
!isTogether &&
|
||||
!is(["cloudflare-ai-gateway"], ["gateway.ai.cloudflare.com"]) &&
|
||||
!is(["nvidia"], ["integrate.api.nvidia.com"]) &&
|
||||
!isAntLing;
|
||||
return {
|
||||
format:
|
||||
typeof compat.thinkingFormat === "string"
|
||||
? compat.thinkingFormat
|
||||
: detectedFormat,
|
||||
effort:
|
||||
typeof compat.supportsReasoningEffort === "boolean"
|
||||
? compat.supportsReasoningEffort
|
||||
: detectedEffort,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi 会不会把档位原样作为 `reasoning_effort` 发给上游。Pi 官方只在这种协议下用
|
||||
* effort 档位生成映射(`supportsDirectReasoningEffort`):Responses 总是;Chat
|
||||
* Completions 看 `piCompletionsThinking`。Anthropic 等协议还需要额外的 compat,
|
||||
* 不生成。
|
||||
*/
|
||||
export function piSendsReasoningEffort(protocol: PiProviderProtocol): boolean {
|
||||
if (protocol.api === "openai-responses") return true;
|
||||
if (protocol.api !== "openai-completions") return false;
|
||||
const { format, effort } = piCompletionsThinking(protocol);
|
||||
return format === "openai" && effort;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设映射能否用在这一行:协议必须相同,预设显式写的 compat 不能与用户已写的值
|
||||
* 冲突;Chat Completions 还要求补上缺的 compat 之后,Pi 探测出的思考格式和是否
|
||||
* 发送 `reasoning_effort` 与预设一致(预设可能依赖 Pi 按地址探测的默认值)。
|
||||
* 能用时返回映射和需要补进模型的 compat。
|
||||
*/
|
||||
export function piPresetThinkingFor(
|
||||
piThinking: KnownModelMetadata["piThinking"],
|
||||
effective: PiProviderProtocol,
|
||||
): { map: PiThinkingLevelMap; missingCompat: Record<string, unknown> } | null {
|
||||
if (!piThinking || piThinking.api !== effective.api) {
|
||||
return null;
|
||||
}
|
||||
const missingCompat: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(piThinking.compat ?? {})) {
|
||||
if (effective.compat[key] === undefined) missingCompat[key] = value;
|
||||
else if (JSON.stringify(effective.compat[key]) !== JSON.stringify(value)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (piThinking.api === "openai-completions") {
|
||||
const preset = piCompletionsThinking({
|
||||
baseUrl: piThinking.baseUrl,
|
||||
providerId: "",
|
||||
compat: piThinking.compat ?? {},
|
||||
});
|
||||
const actual = piCompletionsThinking({
|
||||
...effective,
|
||||
compat: { ...effective.compat, ...missingCompat },
|
||||
});
|
||||
if (preset.format !== actual.format || preset.effort !== actual.effort) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { map: { ...piThinking.thinkingLevelMap }, missingCompat };
|
||||
}
|
||||
@@ -136,6 +136,12 @@ export const piPresetModelSources = lazy(() =>
|
||||
maxOutputTokens: positiveInteger(model.maxTokens),
|
||||
reasoning: model.reasoning,
|
||||
inputModalities: stringList(model.input),
|
||||
piThinking: model.thinkingLevelMap && {
|
||||
thinkingLevelMap: model.thinkingLevelMap,
|
||||
api: preset.settingsConfig.api,
|
||||
baseUrl: preset.settingsConfig.baseUrl,
|
||||
compat: model.compat,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -110,10 +110,12 @@ export function useModelMetadataFill({
|
||||
(
|
||||
modelId: string,
|
||||
apply: (metadata: ResolvedModelMetadata) => boolean,
|
||||
/** 这一行实际请求的地址(模型级覆盖时传入),默认用表单的地址。 */
|
||||
baseUrl?: string,
|
||||
): void => {
|
||||
const resolve = (modelsDev?: ModelsDevResponse) =>
|
||||
resolveModelMetadata(modelId, {
|
||||
baseUrl: baseUrlRef.current,
|
||||
baseUrl: baseUrl || baseUrlRef.current,
|
||||
presets: presetsRef.current(),
|
||||
modelsDev,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,16 @@ export interface KnownModelMetadata {
|
||||
reasoning?: boolean;
|
||||
/** 模型接受的思考档位(models.dev `reasoning_options` 里 effort 类的取值)。 */
|
||||
reasoningEfforts?: string[];
|
||||
/**
|
||||
* 同地址 Pi 预设里人工核对过的思考档位映射。映射只在预设的协议和 compat 下
|
||||
* 成立(例如 Moonshot 要靠 compat 打开 `reasoning_effort`),三者一起带出。
|
||||
*/
|
||||
piThinking?: {
|
||||
thinkingLevelMap: Readonly<Record<string, string | null>>;
|
||||
api: string;
|
||||
baseUrl: string;
|
||||
compat?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
inputModalities?: string[];
|
||||
outputModalities?: string[];
|
||||
/** 美元 / 百万 token。只取自同一家供应商,原厂兜底不带价格。 */
|
||||
@@ -299,7 +309,8 @@ function fieldCount(metadata: KnownModelMetadata): number {
|
||||
* 1. 同地址预设里手工核实过的那一行(同一个模型各家部署的窗口差很多,
|
||||
* 比如 glm-5.2 从 200K 到 1M 都有,预设写的就是这一家的真实值);
|
||||
* 2. models.dev 里按地址认出的同一家供应商;
|
||||
* 3. models.dev 里的原厂条目(不带价格:转售价不等于原厂价),
|
||||
* 3. models.dev 里的原厂条目(不带价格:转售价不等于原厂价;思考档位照用,
|
||||
* 中转站绝大多数与原厂一致),
|
||||
* 认出了供应商也照样用它补供应商条目里没写的字段。
|
||||
* 都查不到返回 null。
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import { http, HttpResponse } from "msw";
|
||||
import { server } from "../msw/server";
|
||||
import { renderWithQueryClient as render } from "../utils/testQueryClient";
|
||||
import { MODELS_DEV_API_URL } from "@/lib/modelsDev";
|
||||
import { piThinkingProfiles } from "@/config/piThinkingProfiles";
|
||||
|
||||
const TAURI_ENDPOINT = "http://tauri.local";
|
||||
|
||||
@@ -1317,6 +1318,12 @@ describe("PiProviderForm", () => {
|
||||
models: {
|
||||
"gpt-5.6-sol": {
|
||||
reasoning: true,
|
||||
reasoning_options: [
|
||||
{
|
||||
type: "effort",
|
||||
values: ["low", "medium", "high", "xhigh"],
|
||||
},
|
||||
],
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 400000, output: 128000 },
|
||||
},
|
||||
@@ -1379,13 +1386,72 @@ describe("PiProviderForm", () => {
|
||||
expect(screen.getByLabelText("pi.form.maxTokens")).toHaveValue(128000);
|
||||
expect(screen.getByLabelText("pi.form.reasoning")).toBeChecked();
|
||||
expect(screen.getByLabelText("pi.form.imageInput")).toBeChecked();
|
||||
// 思考档位映射是 Pi 自己的语义,外部目录给不出,不自动生成。
|
||||
// Chat Completions 会把档位原样作为 reasoning_effort 发出,按 Pi 官方规则生成映射。
|
||||
const configEditor = screen.getByLabelText(
|
||||
"provider.configJson",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(
|
||||
JSON.parse(configEditor.value).models[0].thinkingLevelMap,
|
||||
).toBeUndefined();
|
||||
expect(JSON.parse(configEditor.value).models[0].thinkingLevelMap).toEqual({
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
max: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the compat a preset thinking map depends on", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post(`${TAURI_ENDPOINT}/fetch_models_for_config`, () =>
|
||||
HttpResponse.json([{ id: "kimi-k3", ownedBy: "moonshot" }]),
|
||||
),
|
||||
http.get(MODELS_DEV_API_URL, () => HttpResponse.error()),
|
||||
);
|
||||
|
||||
render(
|
||||
<PiProviderForm
|
||||
appId="pi"
|
||||
submitLabel="Save manual provider"
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "providerPreset.custom" }),
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("my-provider"), {
|
||||
target: { value: "my-moonshot" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("pi.form.credential"), {
|
||||
target: { value: "literal-key" },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("https://api.example.com/v1"),
|
||||
{ target: { value: "https://api.moonshot.cn/v1" } },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "pi.form.addModel" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "providerForm.fetchModels" }),
|
||||
);
|
||||
const modelIdInput = screen.getByLabelText("pi.form.modelId");
|
||||
await user.click(
|
||||
within(modelIdInput.parentElement as HTMLElement).getByRole("button"),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: "kimi-k3" }));
|
||||
|
||||
// Moonshot 的地址在 Pi 里默认不发 reasoning_effort,映射要靠预设的 compat 才生效。
|
||||
const configEditor = screen.getByLabelText(
|
||||
"provider.configJson",
|
||||
) as HTMLTextAreaElement;
|
||||
await waitFor(() =>
|
||||
expect(JSON.parse(configEditor.value).models[0]).toMatchObject({
|
||||
thinkingLevelMap: piThinkingProfiles.kimi3.map,
|
||||
compat: { supportsReasoningEffort: true, thinkingFormat: "openai" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a preset thinking map when the user changes the API", async () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
fillHermesModel,
|
||||
fillOpenClawModel,
|
||||
fillOpenCodeModel,
|
||||
piPresetThinkingFor,
|
||||
piSendsReasoningEffort,
|
||||
piThinkingLevelMapFromEfforts,
|
||||
} from "@/components/providers/forms/modelMetadataFill";
|
||||
import type { KnownModelMetadata } from "@/lib/modelMetadata";
|
||||
|
||||
@@ -125,3 +128,140 @@ describe("fillOpenCodeModel", () => {
|
||||
expect(fillOpenCodeModel({ name: "m" }, {})).toEqual({ name: "m" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("piThinkingLevelMapFromEfforts", () => {
|
||||
it("follows Pi's own models.dev rule", () => {
|
||||
expect(piThinkingLevelMapFromEfforts(["high", "max"])).toEqual({
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: "max",
|
||||
});
|
||||
expect(piThinkingLevelMapFromEfforts(["none", "low"])).toMatchObject({
|
||||
off: "none",
|
||||
low: "low",
|
||||
high: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("generates nothing without a Pi level", () => {
|
||||
expect(piThinkingLevelMapFromEfforts(undefined)).toBeUndefined();
|
||||
expect(piThinkingLevelMapFromEfforts(["default"])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("piSendsReasoningEffort", () => {
|
||||
const base = {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://relay.example.com/v1",
|
||||
providerId: "relay",
|
||||
compat: {},
|
||||
};
|
||||
|
||||
it("accepts Responses and plain Chat Completions endpoints", () => {
|
||||
expect(piSendsReasoningEffort({ ...base, api: "openai-responses" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(piSendsReasoningEffort(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects protocols and endpoints with their own thinking format", () => {
|
||||
expect(piSendsReasoningEffort({ ...base, api: "anthropic-messages" })).toBe(
|
||||
false,
|
||||
);
|
||||
for (const baseUrl of [
|
||||
"https://api.deepseek.com",
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://api.moonshot.cn/v1",
|
||||
]) {
|
||||
expect(piSendsReasoningEffort({ ...base, baseUrl })).toBe(false);
|
||||
}
|
||||
expect(piSendsReasoningEffort({ ...base, providerId: "zai" })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets explicit compat override the detection", () => {
|
||||
expect(
|
||||
piSendsReasoningEffort({
|
||||
...base,
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
compat: { thinkingFormat: "openai" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
piSendsReasoningEffort({
|
||||
...base,
|
||||
compat: { supportsReasoningEffort: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("piPresetThinkingFor", () => {
|
||||
const piThinking = {
|
||||
thinkingLevelMap: { low: "low", high: "high" },
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
compat: { supportsReasoningEffort: true, thinkingFormat: "openai" },
|
||||
};
|
||||
const effective = {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
providerId: "my-moonshot",
|
||||
compat: {},
|
||||
};
|
||||
|
||||
it("returns the map with the compat it still needs", () => {
|
||||
expect(
|
||||
piPresetThinkingFor(piThinking, {
|
||||
...effective,
|
||||
compat: { thinkingFormat: "openai" },
|
||||
}),
|
||||
).toEqual({
|
||||
map: { low: "low", high: "high" },
|
||||
missingCompat: { supportsReasoningEffort: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the map under another protocol or conflicting compat", () => {
|
||||
expect(
|
||||
piPresetThinkingFor(piThinking, {
|
||||
...effective,
|
||||
api: "anthropic-messages",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
piPresetThinkingFor(piThinking, {
|
||||
...effective,
|
||||
compat: { supportsReasoningEffort: false },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(piPresetThinkingFor(undefined, effective)).toBeNull();
|
||||
});
|
||||
|
||||
it("checks the behavior a preset relies on through Pi's own detection", () => {
|
||||
// OpenCode Go 的预设没写 compat,靠 Pi 按地址探测出会发 reasoning_effort。
|
||||
const relying = {
|
||||
thinkingLevelMap: { high: "high", max: "max" },
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
};
|
||||
const goEndpoint = {
|
||||
...effective,
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
};
|
||||
expect(piPresetThinkingFor(relying, goEndpoint)?.map).toEqual({
|
||||
high: "high",
|
||||
max: "max",
|
||||
});
|
||||
expect(
|
||||
piPresetThinkingFor(relying, {
|
||||
...goEndpoint,
|
||||
compat: { supportsReasoningEffort: false },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,22 @@ import { resolveModelMetadata } from "@/lib/modelMetadata";
|
||||
import type { ModelsDevResponse } from "@/lib/modelsDev";
|
||||
|
||||
describe("preset model metadata sources", () => {
|
||||
it("carries a reviewed Pi thinking map with the protocol it depends on", () => {
|
||||
const [source, id, metadata] = piPresetModelSources()
|
||||
.flatMap((source) =>
|
||||
[...source.models].map(
|
||||
([id, metadata]) => [source, id, metadata] as const,
|
||||
),
|
||||
)
|
||||
.find(([, , metadata]) => metadata.piThinking?.compat !== undefined)!;
|
||||
expect(
|
||||
resolveModelMetadata(id, {
|
||||
baseUrl: source.baseUrl,
|
||||
presets: piPresetModelSources(),
|
||||
})?.piThinking,
|
||||
).toEqual(metadata.piThinking);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["codex", codexPresetModelSources],
|
||||
["openclaw", openclawPresetModelSources],
|
||||
|
||||
@@ -72,6 +72,15 @@ describe("useModelMetadataFill", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("looks a row up by its own address when it overrides the form's", () => {
|
||||
server.use(http.get(MODELS_DEV_API_URL, () => HttpResponse.error()));
|
||||
const { result } = renderFill();
|
||||
const apply = vi.fn(() => true);
|
||||
|
||||
result.current("model-x", apply, "https://other.example.com/v1");
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps preset values and stays quiet when models.dev is unreachable", async () => {
|
||||
server.use(http.get(MODELS_DEV_API_URL, () => HttpResponse.error()));
|
||||
const { result } = renderFill();
|
||||
|
||||
@@ -16,7 +16,10 @@ const modelsDev: ModelsDevResponse = {
|
||||
id: "glm-5",
|
||||
limit: { context: 204800, output: 131072 },
|
||||
reasoning: true,
|
||||
reasoning_options: [{ type: "toggle" }],
|
||||
reasoning_options: [
|
||||
{ type: "toggle" },
|
||||
{ type: "effort", values: ["high"] },
|
||||
],
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
cost: { input: 1, output: 3.2 },
|
||||
},
|
||||
@@ -148,10 +151,10 @@ describe("resolveModelMetadata", () => {
|
||||
contextWindow: 204800,
|
||||
reasoning: true,
|
||||
inputModalities: ["text"],
|
||||
reasoningEfforts: ["high"],
|
||||
sources: ["models-dev"],
|
||||
});
|
||||
expect(metadata?.cost).toBeUndefined();
|
||||
expect(metadata?.reasoningEfforts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the price when the provider itself is recognized", () => {
|
||||
@@ -159,8 +162,11 @@ describe("resolveModelMetadata", () => {
|
||||
resolveModelMetadata("glm-5", {
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
modelsDev,
|
||||
})?.cost,
|
||||
).toEqual({ input: 1, output: 3.2 });
|
||||
}),
|
||||
).toMatchObject({
|
||||
cost: { input: 1, output: 3.2 },
|
||||
reasoningEfforts: ["high"],
|
||||
});
|
||||
});
|
||||
|
||||
it("gives up when the same name points at different vendors", () => {
|
||||
|
||||
Reference in new issue
Block a user