Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/main/ipc/__tests__/model-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ afterEach(() => {
})

describe('model metadata synchronization through IPC and runtime', () => {
it('recomputes read-only protocol metadata and strips renderer copies before saving', () => {
const [imported] = setAliases('relay', [glm.id])
const emit = vi.spyOn(windows, 'emitToAll')
expect(imported?.runtimeProtocol).toBe('openai-chat')
expect(getRouter().resolveModel(glm.id, 'relay')?.runtimeProtocol).toBe('openai-chat')
updateModel({ ...imported!, runtimeProtocol: 'anthropic', displayName: 'Renamed' })
expect(store.listAliases().find((row) => row.providerId === 'relay')).not.toHaveProperty('runtimeProtocol')
const provider = store.listProviders().find((row) => row.id === 'relay')!
upsertProvider({ ...provider, protocol: 'anthropic' })
const projected = listModels('relay')[0]!
expect(projected.runtimeProtocol).toBe('anthropic')
expect(getRouter().resolveModel(glm.id, 'relay')?.runtimeProtocol).toBe('anthropic')
expect(emit).toHaveBeenLastCalledWith('provider:changed', expect.objectContaining({
models: expect.arrayContaining([expect.objectContaining({ providerId: 'relay', runtimeProtocol: 'anthropic' })])
}))
updateModel({ ...projected, protocolOverride: 'openai-responses' })
expect(listModels('relay')[0]?.runtimeProtocol).toBe('openai-responses')
expect(getRouter().resolveModel(glm.id, 'relay')?.runtimeProtocol).toBe('openai-responses')
expect(store.listAliases().find((row) => row.providerId === 'relay')).not.toHaveProperty('runtimeProtocol')
})

it('imports capabilities, limits, thinking and adapters, using the same resolved row for runtime', () => {
const [model] = setAliases('relay', ['z-ai/GLM-5.3-Flash'])
expect(model).toMatchObject({ thinkingConfig: glm.thinkingConfig, reasoningEfforts: ['low', 'high', 'max'],
Expand Down Expand Up @@ -86,6 +107,33 @@ describe('model metadata synchronization through IPC and runtime', () => {
expect(row?.protocolOverride).toBeUndefined()
})

it('rejects an adaptive default Off without Off support and keeps the saved declaration', () => {
const [model] = setAliases('relay', ['claude-opus-5-5'])
expect(() => updateModel({ ...model!,
thinkingConfig: { ...model!.thinkingConfig!, defaultEnabled: false } })).toThrow(/配置无效/u)
expect(listModels('relay')[0]?.thinkingConfig?.defaultEnabled).toBe(true)
})

it('accepts default Off on an adaptive binding with confirmed Off support', () => {
const [model] = setAliases('relay', ['claude-opus-5'])
expect(updateModel({ ...model!,
thinkingConfig: { ...model!.thinkingConfig!, defaultEnabled: false } }).thinkingConfig)
.toMatchObject({ defaultEnabled: false, anthropicAdaptive: true })
})

it('clears the adaptive marker on save when the reasoning editor changes its mode or path', () => {
const [model] = setAliases('relay', ['claude-opus-5-5'])
for (const thinkingConfig of [
{ ...model!.thinkingConfig!, anthropicAdaptive: undefined, parameterPath: 'reasoning_effort' },
{ mode: 'budget' as const, defaultEnabled: true, defaultBudgetTokens: 4096,
parameterPath: 'thinking.budget_tokens', anthropicAdaptive: undefined }
]) {
const saved = updateModel({ ...model!, thinkingConfig })
expect(saved.thinkingConfig?.anthropicAdaptive).toBeUndefined()
expect(saved.thinkingConfig?.parameterPath).toBe(thinkingConfig.parameterPath)
}
})

it('keeps effort parameter paths on save and rejects inconsistent supported/default efforts', () => {
const [model] = setAliases('relay', [glm.id])
expect(updateModel({ ...model!, thinkingConfig: { ...model!.thinkingConfig!, defaultBudgetTokens: undefined } })
Expand Down
70 changes: 69 additions & 1 deletion src/main/kernel/upstream/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { ProviderStreamEvent } from '../../../../shared/agent/stream'
import type { ModelAlias, UpstreamProvider } from '../../../../shared/domain/provider'
import { IMPORTED_ALIAS_DEFAULTS, type ModelAlias, type ThinkingConfig, type UpstreamProvider } from '../../../../shared/domain/provider'
import { modelBindingResolver } from '../../../../shared/domain/model-binding'
import { CLIENT_PROVIDER_ID } from '../../../../shared/domain/presets'
import type { UnpricedUsageAttempt } from '../../../../shared/domain/usage'
import type { RunCost } from '../../../../shared/domain/pricing'
Expand Down Expand Up @@ -689,6 +690,73 @@ describe('UpstreamRouter · 正常路径', () => {
])
})

it.each([
['claude-opus-5', 'auto', 'high'],
['claude-opus-5', 'high', 'high'],
['claude-opus-5-5', 'auto', 'medium'],
['claude-opus-5-5', 'high', 'high'],
['claude-haiku-5-5', 'auto', 'medium'],
['claude-haiku-5-5', 'high', 'high'],
['claude-sonnet-5-5', 'auto', 'high'],
['claude-sonnet-5-5', 'high', 'high'],
['gateway/claude-opus-6-1', 'auto', 'high'],
['gateway/claude-opus-6-1', 'high', 'high']
] as const)('%s %s → effort %s,解析绑定后 adaptive 且不带预算', async (upstreamModel, level, effort) => {
const resolved = modelBindingResolver().resolve({
...structuredClone(IMPORTED_ALIAS_DEFAULTS),
alias: 'm', providerId: 'p1', upstreamModel
})
const { router, bodies } = rig({
providers: [provider('p1')], aliases: [resolved], responses: [ok(sseBody({ text: 'x' }))]
})
const output = await drainRequest(router, { ...REQ, thinkingLevel: level, maxOutputTokens: 8_192 })
expect(bodies[0]?.thinking).toEqual({ type: 'adaptive' })
expect(bodies[0]?.output_config).toEqual({ effort })
expect(JSON.stringify(bodies[0])).not.toContain('budget_tokens')
expect(bodies[0]?.max_tokens).toBe(8_192)
expect(output.at(-1)).toMatchObject({ type: 'message_end' })
})

it.each([
['claude-opus-5', 'auto', 'high'],
['claude-opus-5', 'low', 'low'],
['claude-opus-5', 'medium', 'medium'],
['claude-opus-5', 'high', 'high'],
['claude-opus-5', 'higher', 'xhigh'],
['claude-opus-5', 'max', 'max'],
['claude-opus-5-5', 'auto', 'medium'],
['claude-opus-5-5', 'low', 'low'],
['claude-opus-5-5', 'medium', 'medium'],
['claude-opus-5-5', 'high', 'high'],
['claude-opus-5-5', 'higher', 'xhigh'],
['claude-opus-5-5', 'max', 'max']
] as const)('%s 已保存预算 %s 在 1024 输出下仍是 effort %s,预算不发出且配置不变', async (upstreamModel, level, effort) => {
const saved: ThinkingConfig = {
mode: 'budget',
defaultEnabled: true,
defaultBudgetTokens: 60_000,
parameterPath: 'thinking.budget_tokens'
}
const resolved = modelBindingResolver().resolve({
...structuredClone(IMPORTED_ALIAS_DEFAULTS),
alias: 'm', providerId: 'p1', upstreamModel,
thinkingConfig: structuredClone(saved),
catalogOverrides: ['thinkingConfig']
})
expect(resolved.thinkingConfig).toEqual(saved)
expect(resolved.catalogOverrides).toContain('thinkingConfig')
const { router, bodies } = rig({
providers: [provider('p1')], aliases: [resolved], responses: [ok(sseBody({ text: 'x' }))]
})
const output = await drainRequest(router, { ...REQ, thinkingLevel: level, maxOutputTokens: 1024 })
expect(bodies[0]?.thinking).toEqual({ type: 'adaptive' })
expect(bodies[0]?.output_config).toEqual({ effort })
expect(JSON.stringify(bodies[0])).not.toContain('budget_tokens')
expect(bodies[0]?.max_tokens).toBe(1024)
expect(resolved.thinkingConfig).toEqual(saved)
expect(output.at(-1)).toMatchObject({ type: 'message_end' })
})

it('目录 ThinkingConfig 在 Anthropic 请求体上真实生效', async () => {
const a = alias('m', 'p1')
a.thinkingConfig = {
Expand Down
11 changes: 9 additions & 2 deletions src/main/kernel/upstream/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,15 @@ export class UpstreamRouter {
* 被静默跳过 —— 那是个只在供应商刚挂过之后才复现的幽灵 bug。
*/
resolveModel(model: string, modelProviderId?: string): ModelAlias | undefined {
return modelBindingsFor(
this.config.aliases(), this.config.providers(), model, modelProviderId
const providers = this.config.providers()
const alias = modelBindingsFor(
this.config.aliases(), providers, model, modelProviderId
)[0]
if (alias === undefined) return undefined
const provider = providers.find((p) => p.id === alias.providerId)
return provider === undefined
? undefined
: { ...alias, runtimeProtocol: effectiveModelProtocol(provider, alias) }
}

health(): ProviderHealth[] {
Expand Down Expand Up @@ -655,6 +661,7 @@ export class UpstreamRouter {
upstreamModel: c.alias.upstreamModel,
config: thinkingConfig,
reasoning: reasoningFor(req, c.alias),
...(req.thinkingLevel === undefined ? {} : { thinkingLevel: req.thinkingLevel }),
maxOutputTokens: req.maxOutputTokens,
// Legacy bindings without a request adapter must retain automatic
// model-specific thinking detection. Explicit adapters still win;
Expand Down
136 changes: 131 additions & 5 deletions src/main/scheduled/__tests__/scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ScheduledTask } from '../../../shared/domain/scheduled'
import type { RunRequest, ThinkingLevel } from '../../../shared/agent/run-request'
import type { ModelAlias, ReasoningEffort, ThinkingConfig } from '../../../shared/domain/provider'
import type { ScheduledRun, ScheduledTask } from '../../../shared/domain/scheduled'
import type { Workspace } from '../../../shared/domain/workspace'
import { DEFAULT_WORKSPACE_SETTINGS } from '../../../shared/domain/workspace'

const state = vi.hoisted(() => ({
tasks: [] as ScheduledTask[],
workspace: undefined as Workspace | undefined,
model: undefined as ModelAlias | undefined,
sessions: [] as Array<{ thinking: ThinkingLevel }>,
requests: [] as RunRequest[],
scheduledRuns: [] as ScheduledRun[],
resolved: [] as Array<{ model: string; providerId: string | undefined }>
}))

const state = vi.hoisted(() => ({ tasks: [] as ScheduledTask[] }))
vi.mock('electron', () => ({ Notification: { isSupported: () => false } }))
vi.mock('../../runtime', () => ({ runAgent: vi.fn() }))
vi.mock('../../runtime', () => ({
runAgent: vi.fn(),
getRouter: () => ({
resolveModel: (model: string, providerId?: string) => {
state.resolved.push({ model, providerId })
return state.model
}
})
}))
vi.mock('../../window/registry', () => ({ windows: { emitToAll: vi.fn() } }))
vi.mock('../../state/store', () => ({ store: { listScheduledTasks: () => state.tasks } }))
vi.mock('../../state/store', () => ({
store: {
listScheduledTasks: () => state.tasks,
getScheduledTask: (id: string) => state.tasks.find((task) => task.id === id),
getWorkspace: (id: string) => (state.workspace?.id === id ? state.workspace : undefined),
createSession: (input: { thinking: ThinkingLevel }) => {
state.sessions.push({ thinking: input.thinking })
return input
},
putScheduledRun: (run: ScheduledRun) => {
state.scheduledRuns.push(run)
return run
},
getHistory: () => [],
putScheduledTask: (task: ScheduledTask) => {
const index = state.tasks.findIndex((item) => item.id === task.id)
if (index >= 0) state.tasks[index] = task
return task
},
getScheduledRun: (id: string) => [...state.scheduledRuns].reverse().find((run) => run.id === id),
getSettings: () => ({ locale: 'zh-CN' })
}
}))

import { startScheduler, stopScheduler } from '../scheduler'
import { runs } from '../../kernel/run-registry'
import { runAgent } from '../../runtime'
import { runScheduledTaskNow, startScheduler, stopScheduler } from '../scheduler'

beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-06T00:00:00Z'))
state.tasks = []
state.workspace = undefined
state.model = undefined
state.sessions = []
state.requests = []
state.scheduledRuns = []
state.resolved = []
vi.mocked(runAgent).mockImplementation(async (handle, request) => {
state.requests.push(request)
handle.finish('done')
})
})

afterEach(() => {
stopScheduler()
runs.clearForTest()
vi.useRealTimers()
vi.restoreAllMocks()
})
Expand Down Expand Up @@ -52,3 +108,73 @@ describe('scheduler timer bounds', () => {
expect(vi.getTimerCount()).toBe(0)
})
})

const BUDGET: ThinkingConfig = {
mode: 'budget', defaultEnabled: true, defaultBudgetTokens: 60_000,
parameterPath: 'thinking.budget_tokens'
}

const OPUS_46_EFFORT: ThinkingConfig = { mode: 'effort', defaultEnabled: true, defaultEffort: 'high' }
const OPUS_46_EFFORTS = ['none', 'low', 'medium', 'high', 'max'] as const satisfies readonly ReasoningEffort[]

/** Saved budget is what resolve returns; normalization is read-only and must not rewrite the workspace default. */
const THINKING_CASES: ReadonlyArray<{
label: string
upstreamModel: string
defaultThinking: ThinkingLevel
expected: ThinkingLevel
thinkingConfig: ThinkingConfig
reasoningEfforts?: readonly ReasoningEffort[]
}> = [
{ label: 'saved budget opus 5.5 off normalizes to auto', upstreamModel: 'claude-opus-5-5', defaultThinking: 'off', expected: 'auto', thinkingConfig: BUDGET },
{ label: 'saved budget opus 5.5 minimal normalizes to auto', upstreamModel: 'claude-opus-5-5', defaultThinking: 'minimal', expected: 'auto', thinkingConfig: BUDGET },
{ label: 'saved budget opus 5.5 higher stays higher', upstreamModel: 'claude-opus-5-5', defaultThinking: 'higher', expected: 'higher', thinkingConfig: BUDGET },
{ label: 'opus 4.6 effort higher normalizes to auto', upstreamModel: 'claude-opus-4-6', defaultThinking: 'higher', expected: 'auto', thinkingConfig: OPUS_46_EFFORT, reasoningEfforts: OPUS_46_EFFORTS },
{ label: 'manual budget opus 4.6 minimal stays minimal', upstreamModel: 'claude-opus-4-6', defaultThinking: 'minimal', expected: 'minimal', thinkingConfig: BUDGET },
{ label: 'opus 5 off stays off', upstreamModel: 'claude-opus-5', defaultThinking: 'off', expected: 'off', thinkingConfig: BUDGET }
]

function resolvedAlias(upstreamModel: string, thinkingConfig: ThinkingConfig, reasoningEfforts?: readonly ReasoningEffort[]): ModelAlias {
return {
alias: 'claude-opus', providerId: 'provider-anthropic', upstreamModel,
runtimeProtocol: 'anthropic',
capabilities: { tools: true, vision: true, thinking: true, caching: false },
contextWindow: 1_000_000, maxOutputTokens: 64_000,
thinkingConfig: { ...thinkingConfig },
...(reasoningEfforts === undefined ? {} : { reasoningEfforts: [...reasoningEfforts] })
}
}

describe('scheduler thinking normalization', () => {
it.each(THINKING_CASES)('$label', async (row) => {
const providerId = 'provider-anthropic'
const settings = { ...DEFAULT_WORKSPACE_SETTINGS, defaultThinking: row.defaultThinking, activeSkillIds: [] as string[] }
const savedSettings = structuredClone(settings)
const model = resolvedAlias(row.upstreamModel, row.thinkingConfig, row.reasoningEfforts)
const savedModel = structuredClone(model)
state.model = model
state.workspace = {
id: 'workspace', name: 'Workspace', rootPath: 'F:/tmp/workspace',
settings, createdAt: 1, lastOpenedAt: 1
}
const scheduled: ScheduledTask = {
id: `task-${row.upstreamModel}-${row.defaultThinking}`,
name: 'Nightly', prompt: 'summarize', workspaceId: 'workspace',
model: 'claude-opus', modelProviderId: providerId,
timezone: 'UTC', schedule: { kind: 'daily', time: '09:00' },
repeatWindow: { enabled: false }, enabled: true,
nextRunAt: Date.now() + 86_400_000, createdAt: 1, updatedAt: 1
}
state.tasks = [scheduled]
await runScheduledTaskNow(scheduled.id)
await vi.advanceTimersByTimeAsync(0)
expect(state.resolved).toEqual([{ model: 'claude-opus', providerId }])
expect(state.sessions.map((session) => session.thinking)).toEqual([row.expected])
expect(state.requests.map((request) => request.thinking)).toEqual([row.expected])
expect(state.sessions[0]?.thinking).toBe(state.requests[0]?.thinking)
expect(state.workspace.settings).toEqual(savedSettings)
expect(state.workspace.settings.defaultThinking).toBe(row.defaultThinking)
expect(state.model).toEqual(savedModel)
expect(state.scheduledRuns.at(-1)?.status).toBe('success')
})
})
9 changes: 6 additions & 3 deletions src/main/scheduled/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { ContentPart } from '../../shared/agent/message'
import type { RunRequest } from '../../shared/agent/run-request'
import { nextScheduledOccurrence, SCHEDULED_PERMISSION_MODE, type ScheduledRun, type ScheduledTask } from '../../shared/domain/scheduled'
import { ulid } from '../../shared/util/id'
import { runAgent } from '../runtime'
import { getRouter, runAgent } from '../runtime'
import { normalizeModelThinkingLevel } from '../../shared/domain/model-runtime'
import { runs } from '../kernel/run-registry'
import { store } from '../state/store'
import { windows } from '../window/registry'
Expand Down Expand Up @@ -77,15 +78,17 @@ async function execute(task: ScheduledTask, scheduledAt: number, trigger: 'sched
try {
const workspace = store.getWorkspace(task.workspaceId)
if (workspace === undefined) throw new Error('scheduled.workspaceUnavailable')
const session = store.createSession({ id: sessionId, workspaceId: task.workspaceId, title: task.name, origin: 'scheduled', model: task.model, modelProviderId: task.modelProviderId, mode: workspace.settings.defaultMode, thinking: workspace.settings.defaultThinking, rootPathAtCreation: workspace.rootPath })
const model = getRouter().resolveModel(task.model, task.modelProviderId)
const thinking = normalizeModelThinkingLevel(workspace.settings.defaultThinking, model)
const session = store.createSession({ id: sessionId, workspaceId: task.workspaceId, title: task.name, origin: 'scheduled', model: task.model, modelProviderId: task.modelProviderId, mode: workspace.settings.defaultMode, thinking, rootPathAtCreation: workspace.rootPath })
void session
const startedAt = Date.now()
const runningRun = store.putScheduledRun({ ...run, status: 'running', startedAt })
emit('run', task.id, run.id, runningRun.status)
const request: RunRequest = {
runId, sessionId, workspaceId: task.workspaceId, depth: 0,
input: [{ type: 'text', text: task.prompt }], mode: workspace.settings.defaultMode,
thinking: workspace.settings.defaultThinking, webSearch: workspace.settings.webSearch,
thinking, webSearch: workspace.settings.webSearch,
// 定时任务没有输入框,唯一档位来源就是工作区默认值 —— 漏了这行,用户开了
// 「最大上下文」但半夜的任务仍按 272K 压缩,且无处可查。
maxContext: workspace.settings.maxContext === true,
Expand Down
13 changes: 11 additions & 2 deletions src/main/state/model-bindings.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { modelBindingResolver } from '../../shared/domain/model-binding'
import type { ModelAlias } from '../../shared/domain/provider'
import { effectiveModelProtocol } from '../../shared/domain/provider'
import { store } from './store'

export function listResolvedModels(): ModelAlias[] {
const resolver = modelBindingResolver(store.listUserModelCatalog())
const rank = new Map(store.listProviders().map((p, i) => [p.id, i]))
return store.listAliases().map(resolver.resolve).sort((a, b) =>
const providers = store.listProviders()
const rank = new Map(providers.map((p, i) => [p.id, i]))
const byId = new Map(providers.map((p) => [p.id, p]))
return store.listAliases().map((raw) => {
const alias = resolver.resolve(raw)
const provider = byId.get(alias.providerId)
return provider === undefined
? alias
: { ...alias, runtimeProtocol: effectiveModelProtocol(provider, alias) }
}).sort((a, b) =>
(rank.get(a.providerId) ?? 1e9) - (rank.get(b.providerId) ?? 1e9) ||
(a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER) ||
a.alias.localeCompare(b.alias)
Expand Down
Loading
Loading