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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ jobs:
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libarchive-tools rpm
# build-essential + python3: node-pty ships no Linux prebuild, so it
# must be compiled from source against Electron's ABI during dist.
sudo apt-get install -y --no-install-recommends libarchive-tools rpm build-essential python3

- name: Install dependencies
run: npm ci
Expand Down
5 changes: 5 additions & 0 deletions docs/KUN_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ GUI 启动 Kun 时会按下面的顺序合并配置。
"summaryTimeoutMs": 15000,
"summaryMaxTokens": 1200,
"summaryInputMaxBytes": 98304
},
"runtime": {
"streamIdleTimeoutMs": 45000,
"toolStorm": { "enabled": true, "windowSize": 8, "threshold": 3 },
"toolArgumentRepair": { "maxStringBytes": 524288 }
}
}
```
Expand Down
1 change: 1 addition & 0 deletions electron-builder.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ module.exports = {
'**/kun/package*.json',
'**/kun/node_modules/**/*',
'**/node_modules/better-sqlite3/**/*',
'**/node_modules/node-pty/**/*',
'**/node_modules/bindings/**/*',
'**/node_modules/file-uri-to-path/**/*'
],
Expand Down
1 change: 1 addition & 0 deletions kun/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ Feature flags are intentionally explicit:
- `serve.tokenEconomy` / `tokenEconomyMode` compresses tool descriptions, tool results, and history context while preserving code, paths, commands, URLs, errors, and other high-value signals.
- `contextCompaction` controls fallback long-thread compaction thresholds and summary behavior. Per-model thresholds live in `models.profiles`. Compaction preserves goals, constraints, decisions, touched files, tool outcomes, and unresolved next steps.
- `serve.runtimeTuning.toolStorm` suppresses repeated identical tool calls within a turn so useless tool loops do not keep spending tokens.
- `runtime.streamIdleTimeoutMs` (top-level in `config.json`) caps the idle gap between streaming chunks before a turn fails with `stream_idle_timeout` (default `45000`). Raise it for local model servers that stay silent while prefilling a very large prompt; set `0` to disable the guard.
- `capabilities.web` exposes `web_fetch` and/or `web_search`. The built-in provider can fetch HTTP(S) pages; search requires a provider implementation and may report unavailable.
- `capabilities.skills` scans configured roots for `skill.json` manifests and, when `legacySkillMd` is true, older `SKILL.md` directories.
- `capabilities.attachments` stores image bytes outside thread logs and allows turns to reference `attachmentIds`. Vision-capable models receive image parts; text-only models receive a bounded compressed base64 text fallback.
Expand Down
1 change: 1 addition & 0 deletions kun/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ Kun 默认使用混合存储:`threads/{threadId}/messages.jsonl` 与 `events.j
- `serve.tokenEconomy` / `tokenEconomyMode` 会压缩工具描述、工具结果和历史上下文;保留代码、路径、命令、URL、错误信号等高价值信息,同时省掉重复、超长或二进制 payload。
- `contextCompaction` 控制长会话压缩的兜底阈值和摘要方式;模型级阈值写在 `models.profiles`。压缩时保留目标、约束、决策、已触碰文件、工具结果和未解决事项。
- `serve.runtimeTuning.toolStorm` 会抑制同一回合内重复的相同工具调用,阻止无意义 tool loop 继续烧 token。
- `runtime.streamIdleTimeoutMs`(`config.json` 顶层)限制流式分片之间的最大空闲间隔,超时会以 `stream_idle_timeout` 结束本轮(默认 `45000`)。本地模型预处理超大输入时会长时间静默,可调大此值;填 `0` 表示不限制。
- `capabilities.web` 暴露 `web_fetch` 与/或 `web_search`。内置 provider 负责 HTTP(S) 抓取;搜索功能依赖 provider 实现,未配置时会变为不可用。
- `capabilities.skills` 扫描 `roots` 下的 `skill.json`,并在 `legacySkillMd` 为 `true` 时兼容 `SKILL.md`。
- `capabilities.attachments` 将图片二进制从线程日志剥离,允许回合记录引用 `attachmentIds`。视觉模型直接接收图片部分,纯文本模型走受限文本 fallback。
Expand Down
123 changes: 123 additions & 0 deletions kun/src/adapters/model/compat-model-client.endpoint-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { CompatModelClient } from './compat-model-client.js'
import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js'
import type { ModelEndpointFormat } from '../../contracts/model-endpoint-format.js'
import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js'

// A single provider (OpenCode Go) routes some models over chat completions
// and others over Anthropic Messages. The wire format is resolved per request
// model from its capability metadata, falling back to the provider format.

type CapturedCall = { url: string; body: Record<string, unknown> }

function modelCapabilities(
overrides: Record<string, ModelEndpointFormat>
): (model: string) => ModelCapabilityMetadata {
return (model) => ({
id: model,
inputModalities: ['text'],
outputModalities: ['text'],
supportsToolCalling: true,
messageParts: ['text'],
...(overrides[model] ? { endpointFormat: overrides[model] } : {})
})
}

function fakeFetch(calls: CapturedCall[]): typeof fetch {
return (async (url: string, init: { body: string }) => {
const target = String(url)
calls.push({ url: target, body: JSON.parse(init.body) as Record<string, unknown> })
const json = target.endsWith('/messages')
? { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' }
: { choices: [{ index: 0, finish_reason: 'stop', message: { content: 'ok' } }] }
return new Response(JSON.stringify(json), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}) as unknown as typeof fetch
}

function request(model: string): ModelRequest {
return {
threadId: 't1',
turnId: 'u1',
model,
systemPrompt: 'You are a helpful assistant.',
prefix: [],
history: [],
tools: [],
abortSignal: new AbortController().signal
}
}

async function drain(iterable: AsyncIterable<ModelStreamChunk>): Promise<ModelStreamChunk[]> {
const chunks: ModelStreamChunk[] = []
for await (const chunk of iterable) chunks.push(chunk)
return chunks
}

describe('CompatModelClient per-model endpointFormat', () => {
it('routes an override model to the Anthropic Messages endpoint while others use chat completions', async () => {
const calls: CapturedCall[] = []
const client = new CompatModelClient({
baseUrl: 'https://opencode.ai/zen/go/v1',
apiKey: 'sk-test',
model: 'glm-5.1',
endpointFormat: 'chat_completions',
nonStreaming: true,
fetchImpl: fakeFetch(calls),
modelCapabilities: modelCapabilities({ 'minimax-m3': 'messages' })
})

const messagesChunks = await drain(client.stream(request('minimax-m3')))
const chatChunks = await drain(client.stream(request('glm-5.1')))

// The override model hits /messages with the Anthropic body shape.
expect(calls[0].url).toBe('https://opencode.ai/zen/go/v1/messages')
expect(calls[0].body.max_tokens).toBeDefined()
expect(calls[0].body).not.toHaveProperty('stream_options')

// The non-override model inherits the provider format → /chat/completions.
expect(calls[1].url).toBe('https://opencode.ai/zen/go/v1/chat/completions')
expect(calls[1].body.messages).toBeDefined()

// Both responses still materialize cleanly through their respective parsers.
expect(messagesChunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true)
expect(messagesChunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' })
expect(chatChunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true)
expect(chatChunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' })
})

it('sets the Anthropic auth + version headers only for the messages-routed model', async () => {
const headerCalls: Array<Record<string, string>> = []
const capturingFetch = (async (_url: string, init: { headers: Record<string, string> }) => {
headerCalls.push(init.headers)
const target = String(_url)
const json = target.endsWith('/messages')
? { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' }
: { choices: [{ index: 0, finish_reason: 'stop', message: { content: 'ok' } }] }
return new Response(JSON.stringify(json), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}) as unknown as typeof fetch
const client = new CompatModelClient({
baseUrl: 'https://opencode.ai/zen/go/v1',
apiKey: 'sk-test',
model: 'glm-5.1',
endpointFormat: 'chat_completions',
nonStreaming: true,
fetchImpl: capturingFetch,
modelCapabilities: modelCapabilities({ 'minimax-m3': 'messages' })
})

await drain(client.stream(request('minimax-m3')))
await drain(client.stream(request('glm-5.1')))

expect(headerCalls[0]['anthropic-version']).toBe('2023-06-01')
expect(headerCalls[0]['x-api-key']).toBe('sk-test')
expect(headerCalls[1]['anthropic-version']).toBeUndefined()
expect(headerCalls[1]['x-api-key']).toBeUndefined()
expect(headerCalls[1].Authorization).toBe('Bearer sk-test')
})
})
31 changes: 25 additions & 6 deletions kun/src/adapters/model/compat-model-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,11 @@ export class CompatModelClient implements ModelClient {
yield { kind: 'error', message: 'request was aborted before start' }
return
}
const configuredEndpointFormat = this.endpointFormat()
const requestModel = request.model?.trim() || this.config.model
// Resolve the wire format per request model: a single provider (e.g.
// OpenCode Go) can route some models to chat completions and others to
// Anthropic Messages. Falls back to the provider/runtime format.
const configuredEndpointFormat = this.endpointFormatForModel(requestModel)
const endpointFormat = resolveModelEndpointFormat(configuredEndpointFormat, this.config.baseUrl)
if (!endpointFormat) {
yield {
Expand All @@ -243,7 +247,6 @@ export class CompatModelClient implements ModelClient {
}
const url = buildModelEndpointUrl(this.config.baseUrl, configuredEndpointFormat)
const stream = request.stream ?? !this.config.nonStreaming
const requestModel = request.model?.trim() || this.config.model
const body = this.buildRequestBody(request, stream, { endpointFormat })
if (round) {
round.requestBody = body
Expand Down Expand Up @@ -329,6 +332,17 @@ export class CompatModelClient implements ModelClient {
return normalizeModelEndpointFormat(this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT)
}

/**
* The wire format for a specific model: a per-model override (carried on
* the model's capability metadata) takes precedence over the
* provider/runtime format. Lets one provider mix chat completions and
* Anthropic Messages models (e.g. OpenCode Go's minimax/qwen entries).
*/
private endpointFormatForModel(model: string): ModelEndpointFormat {
const perModel = this.config.modelCapabilities?.(model).endpointFormat
return normalizeModelEndpointFormat(perModel ?? this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT)
}

private modelReasoningFor(model: string): ModelCapabilityMetadata['reasoning'] | undefined {
return this.config.modelCapabilities?.(model).reasoning
}
Expand Down Expand Up @@ -1382,18 +1396,23 @@ export class CompatModelClient implements ModelClient {
const promptDetails = usage.prompt_tokens_details as
| { cached_tokens?: number }
| undefined
const inputDetails = usage.input_tokens_details as
| { cached_tokens?: number }
| undefined
const nativeHit = Number(usage.prompt_cache_hit_tokens ?? 0) || 0
const nativeMiss = Number(usage.prompt_cache_miss_tokens ?? 0) || 0
const hasNativeCache = nativeHit > 0 || nativeMiss > 0
const cachedTokens = Number(promptDetails?.cached_tokens ?? 0) || 0
const cachedTokens = Number(promptDetails?.cached_tokens ?? inputDetails?.cached_tokens ?? 0) || 0
const cacheRead = Number(usage.cache_read_input_tokens ?? 0) || 0
const cacheCreation = Number(usage.cache_creation_input_tokens ?? 0) || 0
// Anthropic-protocol usage (MiniMax et al.) reports input_tokens
// EXCLUDING cache reads/writes; OpenAI-style prompt_tokens includes
// everything and marks the cached subset in prompt_tokens_details.
// everything and marks the cached subset in prompt_tokens_details or
// Responses API input_tokens_details.
const anthropicUsage = usage.prompt_tokens === undefined &&
usage.prompt_eval_count === undefined &&
usage.input_tokens !== undefined
usage.input_tokens !== undefined &&
inputDetails?.cached_tokens === undefined
const reportedPromptTokens = Number(usage.prompt_tokens ?? usage.prompt_eval_count ?? usage.input_tokens ?? 0) || 0
const promptTokens = anthropicUsage
? reportedPromptTokens + cacheRead + cacheCreation
Expand Down Expand Up @@ -1923,7 +1942,7 @@ function applyReasoningEffort(
}
switch (normalized) {
case 'off':
if (nativeDeepSeek) body.thinking = { type: 'disabled' }
if (includeThinking) body.thinking = { type: 'disabled' }
break
case 'low':
case 'medium':
Expand Down
14 changes: 13 additions & 1 deletion kun/src/config/kun-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { expandHomePath } from './kun-config.js'
import { expandHomePath, RuntimeTuningConfigSchema } from './kun-config.js'

describe('RuntimeTuningConfigSchema streamIdleTimeoutMs', () => {
it('accepts a custom timeout, including 0 to disable the guard', () => {
expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 300_000 }).success).toBe(true)
expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 0 }).success).toBe(true)
})

it('rejects negative or fractional timeouts', () => {
expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: -1 }).success).toBe(false)
expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 1.5 }).success).toBe(false)
})
})

describe('expandHomePath', () => {
it('expands Windows-style home-relative paths', () => {
Expand Down
12 changes: 11 additions & 1 deletion kun/src/config/kun-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ export const ModelContextProfileConfigSchema = z
outputModalities: z.array(ModelInputModality).optional(),
supportsToolCalling: z.boolean().optional(),
messageParts: z.array(ModelMessagePartSupport).optional(),
reasoning: ModelReasoningCapabilityMetadata.optional()
reasoning: ModelReasoningCapabilityMetadata.optional(),
// Per-model wire-format override. Omitted means "inherit the
// provider/runtime endpointFormat" — no default coercion here, otherwise
// every model would be pinned to chat_completions.
endpointFormat: z
.preprocess(normalizeModelEndpointFormat, z.enum(MODEL_ENDPOINT_FORMATS))
.optional()
})
.strict()
.superRefine((profile, ctx) => {
Expand Down Expand Up @@ -119,6 +125,10 @@ export const ContextCompactionConfigSchema = z

export const RuntimeTuningConfigSchema = z
.object({
// Max idle gap (ms) between streaming chunks before a turn fails with
// `stream_idle_timeout`. Local LLM servers prefilling a huge prompt can
// stay silent well past the 45s default; `0` disables the guard entirely.
streamIdleTimeoutMs: z.number().int().min(0).optional(),
toolStorm: z
.object({
enabled: z.boolean().optional(),
Expand Down
7 changes: 6 additions & 1 deletion kun/src/contracts/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { MODEL_ENDPOINT_FORMATS } from './model-endpoint-format.js'

export const RUNTIME_CAPABILITY_CONTRACT_VERSION = 1

Expand Down Expand Up @@ -51,7 +52,11 @@ export const ModelCapabilityMetadata = z
supportsToolCalling: z.boolean(),
contextWindowTokens: z.number().int().positive().optional(),
messageParts: z.array(ModelMessagePartSupport).min(1),
reasoning: ModelReasoningCapabilityMetadata.optional()
reasoning: ModelReasoningCapabilityMetadata.optional(),
// Per-model wire-format override. Lets one provider route some models to
// chat completions and others to Anthropic Messages / OpenAI Responses
// (e.g. OpenCode Go). Absent means "inherit the provider/runtime format".
endpointFormat: z.enum(MODEL_ENDPOINT_FORMATS).optional()
})
.strict()
export type ModelCapabilityMetadata = z.infer<typeof ModelCapabilityMetadata>
Expand Down
26 changes: 25 additions & 1 deletion kun/src/loop/model-context-profile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { contextThresholdsForModel } from './model-context-profile.js'
import {
contextThresholdsForModel,
modelCapabilitiesForModel,
modelContextProfilesFromConfig
} from './model-context-profile.js'

describe('contextThresholdsForModel safety cap', () => {
it('caps soft/hard thresholds to 75%/85% of the context window', () => {
Expand Down Expand Up @@ -49,3 +53,23 @@ describe('contextThresholdsForModel safety cap', () => {
expect(thresholds).toEqual(fallback)
})
})

describe('per-model endpointFormat', () => {
it('carries a configured endpointFormat from models.profiles into capabilities', () => {
const profiles = modelContextProfilesFromConfig({
models: {
profiles: {
'minimax-m3': { contextWindowTokens: 256_000, endpointFormat: 'messages' },
'glm-5.1': { contextWindowTokens: 131_072 }
}
}
})
expect(modelCapabilitiesForModel('minimax-m3', profiles).endpointFormat).toBe('messages')
// A model without an override inherits (no endpointFormat emitted).
expect(modelCapabilitiesForModel('glm-5.1', profiles).endpointFormat).toBeUndefined()
})

it('omits endpointFormat for unknown models so they inherit the provider format', () => {
expect(modelCapabilitiesForModel('unknown-model', []).endpointFormat).toBeUndefined()
})
})
10 changes: 8 additions & 2 deletions kun/src/loop/model-context-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ModelMessagePartSupport,
ModelReasoningCapabilityMetadata
} from '../contracts/capabilities.js'
import type { ModelEndpointFormat } from '../contracts/model-endpoint-format.js'

export type ModelContextThresholds = {
softThreshold: number
Expand All @@ -26,6 +27,7 @@ export type ModelContextProfile = ModelContextThresholds & {
supportsToolCalling: boolean
messageParts: readonly ModelMessagePartSupport[]
reasoning?: ModelReasoningCapabilityMetadata
endpointFormat?: ModelEndpointFormat
}

export type ModelContextProfileConfig = {
Expand All @@ -45,6 +47,7 @@ export type ModelContextProfileConfig = {
supportsToolCalling?: boolean
messageParts?: readonly ModelMessagePartSupport[]
reasoning?: ModelReasoningCapabilityMetadata
endpointFormat?: ModelEndpointFormat
}

export type ModelConfig = {
Expand Down Expand Up @@ -148,7 +151,8 @@ export function modelCapabilitiesForModel(
supportsToolCalling: profile?.supportsToolCalling ?? true,
contextWindowTokens: profile?.contextWindowTokens,
messageParts: [...(profile?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS)],
...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {})
...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {}),
...(profile?.endpointFormat ? { endpointFormat: profile.endpointFormat } : {})
}
}

Expand Down Expand Up @@ -232,6 +236,7 @@ function mergeModelContextProfile(
...(input.aliases ?? [])
])
const reasoning = input.reasoning ?? current?.reasoning
const endpointFormat = input.endpointFormat ?? current?.endpointFormat
return {
canonicalModel,
modelIds,
Expand All @@ -244,7 +249,8 @@ function mergeModelContextProfile(
messageParts: uniqueModelCapabilityValues(input.messageParts ?? current?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS),
...(reasoning
? { reasoning: copyReasoningCapability(reasoning) }
: {})
: {}),
...(endpointFormat ? { endpointFormat } : {})
}
}

Expand Down
Loading
Loading