fix(compute): 为 Anthropic adaptive 代际模型规格补齐 adaptiveThinking 声明 (#102)

Opus 5 / Sonnet 5 / Opus 4.8 / Opus 4.7 规格补 spec.extra.adaptiveThinking;各版本规格 family 收窄为自身版本,新增同名 claude-opus / claude-sonnet 家族规格承接兜底,防止声明泄漏给 Opus 4.5 等旧型号;schema 声明 adaptiveThinking 并写明匹配键约束;测试约束代际声明与 id/exact/pattern/family 不变式;presetDataVersion 115 → 116。
This commit is contained in:
2026-09-13 22:39:20 -04:00
committed by GitHub
parent 143b567a84
commit 0be8207e32
5 changed files with 179 additions and 12 deletions

View File

@@ -597,6 +597,89 @@ describe('真实数据全量校验', () => {
assert.equal(deepseekFlash.spec.maxOutputTokens, deepseekFlash0731.spec.maxOutputTokens) assert.equal(deepseekFlash.spec.maxOutputTokens, deepseekFlash0731.spec.maxOutputTokens)
}) })
describe('Anthropic adaptive thinking 代际声明', () => {
// 这些代际收到 thinking.type=enabled + budget_tokens 会直接 400。客户端只在官方
// api.anthropic.com 端点按模型 ID 兜底;desirecore-cloud 等动态 Provider、非官方域名的
// Anthropic Messages 中转只能从 ModelSpec 补全拿到声明,预置 Provider 条目不经补全,
// 两处漏写都会回退成 budget_tokens(desirecore#2940)。Opus 4.6 / Sonnet 4.6 仍接受
// budget_tokens(仅弃用),不在强制范围内。新代际若同样拒绝 budget_tokens,先扩展这里的正则。
const adaptiveGeneration = /^claude-(?:fable|mythos)(?:$|-)|^claude-(?:opus|sonnet)-(?:[5-9]|[1-9]\d)(?:$|-)|^claude-opus-4-[78](?:$|-)/
const thinkingOnlyGeneration = /^claude-(?:fable|mythos)(?:$|-)/
// 与客户端匹配器同口径:小写、去 vendor 前缀、统一分隔符
const normalizeId = (id) => id.toLowerCase().trim().split('/').at(-1).replace(/[._:\s]+/g, '-')
const specsDir = join(ROOT, 'compute', 'model-specs')
const allSpecs = JSON.parse(readFileSync(join(specsDir, '_index.json'), 'utf8')).order
.flatMap((name) => JSON.parse(readFileSync(join(specsDir, `${name}.json`), 'utf8')).specs)
it('规格按代际声明 adaptiveThinking,只有 Fable/Mythos 声明思考恒开', () => {
const adaptiveSpecIds = allSpecs.map((item) => item.id).filter((id) => adaptiveGeneration.test(normalizeId(id)))
for (const id of ['claude-fable-5-1', 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-5']) {
assert.ok(adaptiveSpecIds.includes(id), `model-specs 缺少 ${id}`)
}
for (const modelSpec of allSpecs) {
const id = normalizeId(modelSpec.id)
const extra = modelSpec.spec.extra ?? {}
assert.equal(
extra.adaptiveThinking === true,
adaptiveGeneration.test(id),
`${modelSpec.id} 的 adaptiveThinking 与代际不符;若它是新的拒绝 budget_tokens 代际,先扩展本测试的代际正则`,
)
if (id.startsWith('claude-')) {
assert.equal(extra.thinkingOnly === true, thinkingOnlyGeneration.test(id), `${modelSpec.id} 的 thinkingOnly 与代际不符`)
}
}
})
it('匹配键与 family 兜底不会把代际声明带给其他代际', () => {
// 客户端依次按 id/exact、pattern、最长 family 前缀匹配,并把规格 extra 写进模型条目
// (精确与 pattern 命中时 cloud 同步直接覆盖)。任何一个键过宽——family 写成 claude-opus、
// pattern 写成 claude-opus-4*——Opus 4.5 等旧型号就会继承 adaptiveThinking,
// 连官方端点都被改写成 adaptive 而 400。pattern 取第一个 * 之前的前缀判断。
const matchKeys = (modelSpec) => [
modelSpec.id,
...(modelSpec.family ? [modelSpec.family] : []),
...(modelSpec.match?.exact ?? []),
...(modelSpec.match?.patterns ?? []).map((pattern) => pattern.split('*')[0]),
].map(normalizeId)
for (const modelSpec of allSpecs) {
const extra = modelSpec.spec.extra ?? {}
for (const key of matchKeys(modelSpec)) {
if (extra.adaptiveThinking === true) {
assert.ok(adaptiveGeneration.test(key), `${modelSpec.id} 声明了 adaptiveThinking,匹配键「${key}」必须收窄到同一代际`)
}
if (extra.thinkingOnly === true && key.startsWith('claude-')) {
assert.ok(thinkingOnlyGeneration.test(key), `${modelSpec.id} 声明了 thinkingOnly,匹配键「${key}」必须收窄到同一代际`)
}
}
}
// 未单独收录的 Opus / Sonnet 版本由同名家族规格保守补全,不带任何代际声明
for (const family of ['claude-opus', 'claude-sonnet']) {
const holders = allSpecs.filter((item) => item.family === family)
assert.deepEqual(holders.map((item) => item.id), [family], `${family} 家族兜底应只由同名规格承接`)
assert.deepEqual(holders[0].spec.extra ?? {}, {}, `${family} 家族兜底规格不得声明代际属性`)
}
})
it('预置 anthropic-messages 条目自行声明 adaptiveThinking', () => {
let checked = 0
for (const dir of [join(ROOT, 'compute', 'providers'), join(ROOT, 'compute', 'coding-plans')]) {
for (const file of readdirSync(dir).filter((name) => name.endsWith('.json') && !name.startsWith('_'))) {
const provider = JSON.parse(readFileSync(join(dir, file), 'utf8'))
for (const model of provider.models ?? []) {
const apiFormat = model.apiFormat ?? provider.apiFormat
if (apiFormat !== 'anthropic-messages') continue
const adaptive = adaptiveGeneration.test(normalizeId(model.apiModelId ?? model.modelName))
if (!adaptive) continue
checked += 1
assert.equal(model.extra?.adaptiveThinking, true, `${file} 的 ${model.modelName} 缺少 extra.adaptiveThinking`)
}
}
}
assert.ok(checked > 0, '未检查到任何 adaptive 代际的预置条目,数据结构可能已变化')
})
})
it('所有 Provider 应按供应商归属计价,模型来源不覆盖供应商币种', () => { it('所有 Provider 应按供应商归属计价,模型来源不覆盖供应商币种', () => {
const expectedCurrencies = { const expectedCurrencies = {
anthropic: 'USD', anthropic: 'USD',
@@ -790,6 +873,19 @@ describe('provider schema 反例(防 PR #1 重演)', () => {
assert.equal(validate(data), true, JSON.stringify(validate.errors)) assert.equal(validate(data), true, JSON.stringify(validate.errors))
}) })
it('Provider model 的 adaptiveThinking 只接受布尔值', () => {
for (const value of [true, false]) {
const valid = makeValidProvider()
valid.models[0].extra = { adaptiveThinking: value }
assert.equal(validate(valid), true, JSON.stringify(validate.errors))
}
for (const value of ['true', 1, null]) {
const invalid = makeValidProvider()
invalid.models[0].extra = { adaptiveThinking: value }
assert.equal(validate(invalid), false)
}
})
it('严格校验 Provider model 的 native thinking round-trip 能力', () => { it('严格校验 Provider model 的 native thinking round-trip 能力', () => {
const valid = makeValidProvider() const valid = makeValidProvider()
valid.models[0].extra = { valid.models[0].extra = {
@@ -943,6 +1039,16 @@ describe('model-spec schema 接入面边界', () => {
} }
}) })
it('adaptiveThinking 是模型内在属性,只接受布尔值', () => {
for (const value of [true, false]) {
const data = { specs: [{ id: 'claude-test', spec: { extra: { adaptiveThinking: value } } }] }
assert.equal(validate(data), true, JSON.stringify(validate.errors))
}
for (const value of ['true', 1, null]) {
assert.equal(validate({ specs: [{ id: 'claude-test', spec: { extra: { adaptiveThinking: value } } }] }), false)
}
})
it('仍允许模型内在扩展参数', () => { it('仍允许模型内在扩展参数', () => {
const data = { const data = {
specs: [{ id: 'gpt-test', spec: { extra: { intrinsicBudgetHint: 8192 } } }], specs: [{ id: 'gpt-test', spec: { extra: { intrinsicBudgetHint: 8192 } } }],

View File

@@ -57,7 +57,7 @@
{ {
"id": "claude-opus-5", "id": "claude-opus-5",
"displayName": "Claude Opus 5", "displayName": "Claude Opus 5",
"family": "claude-opus", "family": "claude-opus-5",
"match": { "match": {
"patterns": [ "patterns": [
"claude-opus-5*" "claude-opus-5*"
@@ -78,7 +78,10 @@
"chat" "chat"
], ],
"supportsReasoning": true, "supportsReasoning": true,
"description": "Claude Opus 5,Anthropic Opus 系列当前旗舰;思考默认开启,effort 支持到 max" "description": "Claude Opus 5,Anthropic Opus 系列当前旗舰;思考默认开启,effort 支持到 max",
"extra": {
"adaptiveThinking": true
}
}, },
"routing": { "routing": {
"tier": "flagship", "tier": "flagship",
@@ -101,7 +104,7 @@
{ {
"id": "claude-opus-4-8", "id": "claude-opus-4-8",
"displayName": "Claude Opus 4.8", "displayName": "Claude Opus 4.8",
"family": "claude-opus", "family": "claude-opus-4-8",
"match": { "match": {
"patterns": [ "patterns": [
"claude-opus-4-8*" "claude-opus-4-8*"
@@ -122,13 +125,16 @@
"chat" "chat"
], ],
"supportsReasoning": true, "supportsReasoning": true,
"description": "Claude Opus 4.8,Anthropic Opus 系列最新旗舰" "description": "Claude Opus 4.8,Anthropic Opus 系列最新旗舰",
"extra": {
"adaptiveThinking": true
}
} }
}, },
{ {
"id": "claude-opus-4-7", "id": "claude-opus-4-7",
"displayName": "Claude Opus 4.7", "displayName": "Claude Opus 4.7",
"family": "claude-opus", "family": "claude-opus-4-7",
"match": { "match": {
"patterns": [ "patterns": [
"claude-opus-4-7*" "claude-opus-4-7*"
@@ -149,13 +155,36 @@
], ],
"defaultTemperature": 1, "defaultTemperature": 1,
"supportsReasoning": true, "supportsReasoning": true,
"description": "Anthropic 当前最强通用模型,适合复杂推理和智能体编码任务" "description": "Anthropic 当前最强通用模型,适合复杂推理和智能体编码任务",
"extra": {
"adaptiveThinking": true
}
}
},
{
"id": "claude-opus",
"family": "claude-opus",
"spec": {
"contextWindow": 1000000,
"maxOutputTokens": 128000,
"capabilities": [
"chat",
"reasoning",
"code",
"vision",
"tool_use",
"long_context"
],
"serviceType": [
"chat"
],
"supportsReasoning": true
} }
}, },
{ {
"id": "claude-sonnet-5", "id": "claude-sonnet-5",
"displayName": "Claude Sonnet 5", "displayName": "Claude Sonnet 5",
"family": "claude-sonnet", "family": "claude-sonnet-5",
"match": { "match": {
"patterns": [ "patterns": [
"claude-sonnet-5*" "claude-sonnet-5*"
@@ -177,7 +206,10 @@
"chat" "chat"
], ],
"supportsReasoning": true, "supportsReasoning": true,
"description": "Claude Sonnet 5,速度与智能平衡;思考默认开启,effort 支持到 max" "description": "Claude Sonnet 5,速度与智能平衡;思考默认开启,effort 支持到 max",
"extra": {
"adaptiveThinking": true
}
}, },
"routing": { "routing": {
"tier": "balanced", "tier": "balanced",
@@ -200,7 +232,7 @@
{ {
"id": "claude-sonnet-4-6", "id": "claude-sonnet-4-6",
"displayName": "Claude Sonnet 4.6", "displayName": "Claude Sonnet 4.6",
"family": "claude-sonnet", "family": "claude-sonnet-4-6",
"match": { "match": {
"patterns": [ "patterns": [
"claude-sonnet-4-6*" "claude-sonnet-4-6*"
@@ -229,7 +261,7 @@
{ {
"id": "claude-sonnet-4-5", "id": "claude-sonnet-4-5",
"displayName": "Claude Sonnet 4.5", "displayName": "Claude Sonnet 4.5",
"family": "claude-sonnet", "family": "claude-sonnet-4-5",
"match": { "match": {
"patterns": [ "patterns": [
"claude-sonnet-4-5*" "claude-sonnet-4-5*"
@@ -252,6 +284,27 @@
"supportsReasoning": true, "supportsReasoning": true,
"description": "Anthropic 主力模型,长文本与代码任务表现优异" "description": "Anthropic 主力模型,长文本与代码任务表现优异"
} }
},
{
"id": "claude-sonnet",
"family": "claude-sonnet",
"spec": {
"contextWindow": 1000000,
"maxOutputTokens": 128000,
"capabilities": [
"chat",
"reasoning",
"code",
"vision",
"tool_use",
"computer_use",
"long_context"
],
"serviceType": [
"chat"
],
"supportsReasoning": true
}
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.0.0",
"presetDataVersion": 115, "presetDataVersion": 116,
"updatedAt": "2026-09-14", "updatedAt": "2026-09-14",
"description": "DesireCore 官方配置中心" "description": "DesireCore 官方配置中心"
} }

View File

@@ -259,7 +259,7 @@
}, },
"extra": { "extra": {
"type": "object", "type": "object",
"description": "模型内在开放扩展:modelOrigin 描述真实原厂身份,speech 描述可跨小版本复用的语音协议;不含价格,也不得声明 Provider/接入面的 reasoning effort、thinking 回放能力(thinkingRoundTrip)或工具轮 thinking 校验口径(thinkingToolTurnValidation)——这些只能写在 provider model 的 extra 中,客户端 Schema 会拒收整份规格。", "description": "模型内在开放扩展:modelOrigin 描述真实原厂身份,speech 描述可跨小版本复用的语音协议,thinkingOnly 描述模型是否强制思考,adaptiveThinking 描述 Anthropic 模型是否只接受 adaptive thinking 线上形态;不含价格,也不得声明 Provider/接入面的 reasoning effort、thinking 回放能力(thinkingRoundTrip)或工具轮 thinking 校验口径(thinkingToolTurnValidation)——这些只能写在 provider model 的 extra 中,客户端 Schema 会拒收整份规格。",
"allOf": [ "allOf": [
{ "not": { "required": ["reasoning"] } }, { "not": { "required": ["reasoning"] } },
{ "not": { "required": ["thinkingRoundTrip"] } }, { "not": { "required": ["thinkingRoundTrip"] } },
@@ -272,6 +272,10 @@
"type": "boolean", "type": "boolean",
"description": "模型强制思考、无法关闭(如 GLM-5.3 系列 thinking.type 仅接受 enabled)。true 时客户端把 off 从可选档位移除,并把内部 suppressThinking 降级为 auto,避免发出必然被上游拒绝的关闭参数。只描述模型固有属性,不声明接入面支持哪些深度档位——后者只能写在 provider model 的 extra.reasoning 中。" "description": "模型强制思考、无法关闭(如 GLM-5.3 系列 thinking.type 仅接受 enabled)。true 时客户端把 off 从可选档位移除,并把内部 suppressThinking 降级为 auto,避免发出必然被上游拒绝的关闭参数。只描述模型固有属性,不声明接入面支持哪些深度档位——后者只能写在 provider model 的 extra.reasoning 中。"
}, },
"adaptiveThinking": {
"type": "boolean",
"description": "Anthropic Messages 模型是否属于 adaptive thinking 代际(Claude Opus 4.7+ / Opus 5 / Sonnet 5 / Fable / Mythos)。true 时客户端把思考参数发成 thinking.type=adaptive + output_config.effort,而不是会被上游 400 拒绝的 thinking.type=enabled + budget_tokens;false 表示明确不是,禁止客户端按模型 ID 推断;缺省时客户端仅在官方 api.anthropic.com 端点上按模型代际表兜底。只作用于 anthropic-messages 接入面,Claude 订阅(claude-oauth)由 Claude CLI 自行改写、不受本字段影响。命中本规格的模型由客户端 applySpec 复制到 provider model extra,运行时读的是那份副本——desirecore-cloud 等动态 Provider、非官方域名的 Anthropic Messages 中转只能从这里拿到声明,漏写即回退为 budget_tokens。match.exact、match.patterns 与 family 兜底命中时都会带上本键,因此声明本键的规格,其 exact、pattern 前缀与 family 都必须收窄到同一代际(如 family 写 claude-opus-5 而不是 claude-opus,pattern 写 claude-opus-4-7* 而不是 claude-opus-4*),否则 Opus 4.5 等旧型号会继承声明并被上游 400 拒绝。"
},
"thinkingDefault": { "thinkingDefault": {
"type": "boolean", "type": "boolean",
"description": "模型默认开启思考。当前不改变客户端行为,仅供维护者参考。" "description": "模型默认开启思考。当前不改变客户端行为,仅供维护者参考。"

View File

@@ -254,6 +254,10 @@
"type": "object", "type": "object",
"description": "接入面特定配置:如 TTS 模型音色列表、ASR 支持格式、reasoning effort、native thinking 回放能力与工具轮 thinking 校验口径。这里是兼容扩展面:旧客户端会安全保留并忽略未知子字段。", "description": "接入面特定配置:如 TTS 模型音色列表、ASR 支持格式、reasoning effort、native thinking 回放能力与工具轮 thinking 校验口径。这里是兼容扩展面:旧客户端会安全保留并忽略未知子字段。",
"properties": { "properties": {
"adaptiveThinking": {
"type": "boolean",
"description": "Anthropic Messages 模型是否属于 adaptive thinking 代际(Claude Opus 4.7+ / Opus 5 / Sonnet 5 / Fable / Mythos)。true 时客户端把思考参数发成 thinking.type=adaptive + output_config.effort,而不是会被上游 400 拒绝的 thinking.type=enabled + budget_tokens;false 表示明确不是,禁止客户端按模型 ID 推断;缺省时客户端仅在官方 api.anthropic.com 端点上按模型代际表兜底。只作用于 anthropic-messages 接入面,Claude 订阅(claude-oauth)由 Claude CLI 自行改写、不受本字段影响。这是模型固有属性,权威声明写在 model-spec 的 spec.extra.adaptiveThinking;预置 Provider 条目不经规格库补全,必须在这里同样声明。"
},
"thinkingRoundTrip": { "thinkingRoundTrip": {
"type": "object", "type": "object",
"description": "当前 Provider/model 接入面是否允许回放 Provider 原样返回的 Anthropic thinking。只授权当前未闭合工具轨迹;UI 展示 thinking、已完成旧轮次、跨模型/凭据数据永远没有回放资格。", "description": "当前 Provider/model 接入面是否允许回放 Provider 原样返回的 Anthropic thinking。只授权当前未闭合工具轨迹;UI 展示 thinking、已完成旧轮次、跨模型/凭据数据永远没有回放资格。",