Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.3.4 (2026-09-18)

### Fixed

- **Responses-only 模型路由与思考参数适配(`muse-spark-*`):**
- 将 `muse-spark-*` 模型自动路由至 pi-ai 的 `openai-responses`(`/v1/responses`)API,解决其在 `/chat/completions` 端点返回裸 500 错误的问题。
- 将 responses 模型的流式传输 Body 空闲等待超时窗口从 120 秒放宽至 300 秒(`RESPONSES_BODY_IDLE_MS = 300_000`),以容纳深度思考阶段的阵发性静默。
- 适配 Responses API 规范,将用户选择的思考等级(Minimal/Medium/High 等)注入至 `reasoning.effort` 对象而非根字段 `reasoning_effort`(避免上游报错 `unknown parameter reasoning_effort`);在未选择思考等级(Default)时阻止 pi-ai 默认插入 `effort: 'none'`,避免上游因不支持 `'none'` 而报错。

## 0.3.3 (2026-09-18)

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencode2dsh/dsh-plugin",
"version": "0.3.3",
"version": "0.3.4",
"description": "Free OpenCode Zen models for DeepSeek Harness (DSH): native dsh-llm adapter plugin, no API key.",
"type": "module",
"main": "lib/index.js",
Expand Down
8 changes: 5 additions & 3 deletions packages/plugin/src/adapter/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type PiMessage =
| {
role: 'assistant'
content: PiAssistantBlock[]
api: 'openai-completions'
api: 'openai-completions' | 'openai-responses'
provider: string
model: string
usage: PiUsage
Expand Down Expand Up @@ -120,12 +120,14 @@ function toPiAssistant(message: HarnessMessage, providerId: string): Extract<PiM
}
}
const source = message.source
const model = source?.kind === 'model' && typeof source.model === 'string' ? source.model : providerId
const api = model.startsWith('muse-spark-') ? 'openai-responses' : 'openai-completions'
return {
role: 'assistant',
content,
api: 'openai-completions',
api,
provider: source?.kind === 'model' && typeof source.provider === 'string' ? source.provider : providerId,
model: source?.kind === 'model' && typeof source.model === 'string' ? source.model : providerId,
model,
usage: zeroUsage(),
stopReason: content.some((block) => block.type === 'toolCall') ? 'toolUse' : 'stop',
timestamp: 0,
Expand Down
80 changes: 69 additions & 11 deletions packages/plugin/src/adapter/zen-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createProvider, type Api, type Context, type Model } from '@earendil-works/pi-ai'
import * as openaiCompletions from '@earendil-works/pi-ai/api/openai-completions'
import * as openaiResponses from '@earendil-works/pi-ai/api/openai-responses'

import { ModelCatalog, ZEN_BASE_URL } from './catalog.ts'
import { toStreamChunks, type HarnessChunk, type PiEvent } from './events.ts'
Expand All @@ -21,6 +22,20 @@ import { classifyStreamFailure, isRegionBlocked, shouldRotate } from '../pool/ro

export const PROVIDER_ID = 'opencode2dsh'

/**
* Responses-only models: muse-spark-* fail with bare 500 on /chat/completions,
* but succeed (200) on /responses. They route to pi-ai's openai-responses api.
*/
export const RESPONSES_ONLY_PREFIX = 'muse-spark-'

export function isResponsesOnlyModel(modelId: string): boolean {
return modelId.startsWith(RESPONSES_ONLY_PREFIX)
}

export function apiForModel(modelId: string): 'openai-responses' | 'openai-completions' {
return isResponsesOnlyModel(modelId) ? 'openai-responses' : 'openai-completions'
}

export interface ZenModelInfo {
id: string
name: string
Expand Down Expand Up @@ -112,13 +127,15 @@ export const WATCHDOG_IDLE_MESSAGE = 'opencode2dsh: stream body idle timeout (ex
/** Default watchdog windows (docs/ip-pool.md; test-injectable via constructor). */
export const DEFAULT_FIRST_EVENT_MS = 30_000
export const DEFAULT_BODY_IDLE_MS = 120_000
/** Widened body idle window for reasoning burstiness in responses-only models. */
export const RESPONSES_BODY_IDLE_MS = 300_000

/** The terminal error event pi-ai owes but never sent (watchdog teardown). */
function terminalErrorEvent(errorMessage: string, model: Model<Api>): PiEvent {
return {
type: 'error',
error: {
api: 'openai-completions',
api: model.api,
provider: PROVIDER_ID,
model: model.id,
content: [],
Expand All @@ -130,17 +147,19 @@ function terminalErrorEvent(errorMessage: string, model: Model<Api>): PiEvent {
}

function toPiModel(id: string, reasoning: boolean): Model<Api> {
const isResponses = isResponsesOnlyModel(id)
return {
id,
name: id,
api: 'openai-completions',
api: isResponses ? 'openai-responses' : 'openai-completions',
provider: PROVIDER_ID,
baseUrl: `${ZEN_BASE_URL.replace(/\/+$/, '')}/v1`,
// The honest capability flag: gates pi-ai's reasoning_effort branch and
// keeps developer-role replay suppressed (the Zen lane's compat detects
// supportsDeveloperRole=false for opencode.ai, so the system slot is
// unchanged either way).
reasoning,
...(isResponses ? { thinkingLevelMap: { off: null } } : {}),
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
Expand All @@ -153,17 +172,20 @@ export class ZenAdapter {
readonly #provider: { streamSimple(model: unknown, context: unknown, options: unknown): unknown }
readonly #firstEventMs: number
readonly #bodyIdleMs: number
readonly #responsesBodyIdleMs: number

constructor(catalog: CatalogLike, options: {
zenBaseUrl?: string
providerOverride?: unknown
/** Watchdog windows (tests inject short ones; defaults are live-tuned). */
firstEventMs?: number
bodyIdleMs?: number
responsesBodyIdleMs?: number
} = {}) {
this.#catalog = catalog
this.#firstEventMs = options.firstEventMs ?? DEFAULT_FIRST_EVENT_MS
this.#bodyIdleMs = options.bodyIdleMs ?? DEFAULT_BODY_IDLE_MS
this.#responsesBodyIdleMs = options.responsesBodyIdleMs ?? (options.bodyIdleMs !== undefined ? options.bodyIdleMs : RESPONSES_BODY_IDLE_MS)
if (options.providerOverride !== undefined) {
this.#provider = options.providerOverride as never
return
Expand All @@ -180,7 +202,10 @@ export class ZenAdapter {
},
},
models: [],
api: openaiCompletions,
api: {
'openai-completions': openaiCompletions,
'openai-responses': openaiResponses,
},
})
}

Expand Down Expand Up @@ -273,7 +298,9 @@ export class ZenAdapter {
// behind the pending request), so timeout-promise racing is the only
// mechanism that actually interrupts a hung stream.
const firstEventMs = this.#firstEventMs
const bodyIdleMs = this.#bodyIdleMs
const bodyIdleMs = isResponsesOnlyModel(options.model)
? this.#responsesBodyIdleMs
: this.#bodyIdleMs
const rotateStory: string[] = []
for (let attempt = 0; ; attempt += 1) {
const events = routingContext.run(contextStore, () =>
Expand Down Expand Up @@ -433,6 +460,7 @@ export class ZenAdapter {
ids: ReturnType<typeof deriveRequestIDs>,
model: ReturnType<typeof toPiModel>,
): unknown {
const isResponses = isResponsesOnlyModel(model.id)
// Structural boundary: PiContext (own types, unit-tested) -> pi-ai Context.
// onPayload injects the free-lane gate tools (adapter/messages.ts) into the
// serialized body right before dispatch — plain-chat contexts carry no
Expand All @@ -441,14 +469,44 @@ export class ZenAdapter {
// wire semantics this lane needs (selected off must SEND `none`, not omit),
// so the effort rides the payload rewrite instead.
const effortWire = reasoningEffortWire(options.reasoningEffort)
const onPayload =
effortWire === undefined
? ensureFreeLaneShape
: (payload: unknown): unknown => {
const shaped = ensureFreeLaneShape(payload)
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return shaped
return { ...((shaped ?? payload) as Record<string, unknown>), reasoning_effort: effortWire }
const onPayload = (payload: unknown): unknown => {
const shaped = ensureFreeLaneShape(payload)
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return shaped
const p = { ...((shaped ?? payload) as Record<string, unknown>) } as Record<string, unknown> & {
reasoning?: { effort?: string; [k: string]: unknown }
reasoning_effort?: string
}
if (isResponses) {
// OpenAI Responses API (/v1/responses) uses `reasoning: { effort }`, NOT root `reasoning_effort`.
// Upstream rejects `reasoning_effort` with "unknown parameter reasoning_effort".
delete p.reasoning_effort

if (effortWire !== undefined) {
// Upstream muse-spark rejects 'none'; clamp 'off'/'none' to 'minimal'
const effort = effortWire === 'none' ? 'minimal' : effortWire
p.reasoning = {
...(typeof p.reasoning === 'object' && p.reasoning !== null ? p.reasoning : {}),
effort,
}
} else if (p.reasoning?.effort === 'none') {
// pi-ai defaults reasoning.effort to 'none' when model.reasoning is true;
// upstream rejects 'none', so remove it when user chose default (no effort).
const { effort: _unused, ...rest } = p.reasoning
if (Object.keys(rest).length > 0) {
p.reasoning = rest
} else {
delete p.reasoning
}
}
return p
}

if (effortWire !== undefined) {
p.reasoning_effort = effortWire
return p
}
return shaped
}
return this.#provider.streamSimple(model, context as unknown as Context, {
apiKey: ANONYMOUS_KEY,
sessionId: ids.session,
Expand Down
105 changes: 104 additions & 1 deletion packages/plugin/test/zen-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ModelCatalog } from '../src/adapter/catalog.ts'
import { PROVIDER_ID, reasoningEfforts, reasoningEffortWire, ZenAdapter } from '../src/adapter/zen-adapter.ts'
import { apiForModel, isResponsesOnlyModel, PROVIDER_ID, reasoningEfforts, reasoningEffortWire, RESPONSES_BODY_IDLE_MS, ZenAdapter } from '../src/adapter/zen-adapter.ts'

/**
* The exact method surface dsh-llm touches on a registered adapter. A missing
Expand Down Expand Up @@ -171,3 +171,106 @@ test('stream keeps the free-lane gate rewrite alongside the effort injection', a
// non-chat payloads pass through untouched even with an effort selected
assert.equal(offOptions.onPayload?.(null), undefined)
})

test('responses-only models (muse-spark-*) route to openai-responses api', async () => {
let capturedModel: any
const fakeProvider = {
streamSimple: (model: unknown) => {
capturedModel = model
return (async function* () {
yield { type: 'start', partial: { content: [] } }
yield { type: 'text_delta', contentIndex: 0, delta: 'regular output' }
yield {
type: 'done',
message: {
api: (model as any).api,
provider: 'opencode2dsh',
model: (model as any).id,
content: [{ type: 'text', text: 'regular output' }],
usage: { input: 5, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 10 },
stopReason: 'stop',
},
}
})()
},
}
const adapter = new ZenAdapter(new ModelCatalog(), { providerOverride: fakeProvider })
const stream = adapter.stream({
provider: 'opencode2dsh',
model: 'muse-spark-1.3-contributor',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
})
const chunks = []
for await (const chunk of stream) chunks.push(chunk)
assert.equal(capturedModel.id, 'muse-spark-1.3-contributor')
assert.equal(capturedModel.api, 'openai-responses')
})

test('extended stream body idle watchdog for responses-only models', () => {
assert.equal(RESPONSES_BODY_IDLE_MS, 300_000)
assert.equal(isResponsesOnlyModel('muse-spark-1.3-contributor'), true)
assert.equal(isResponsesOnlyModel('big-pickle'), false)
assert.equal(apiForModel('muse-spark-1.3-contributor'), 'openai-responses')
assert.equal(apiForModel('big-pickle'), 'openai-completions')
})

test('responses-only models route reasoning effort to reasoning.effort and never send root reasoning_effort or effort "none"', async () => {
let capturedOptions: any
const fakeProvider = {
streamSimple: (_model: unknown, _context: unknown, options: unknown) => {
capturedOptions = options
return (async function* () {
yield { type: 'start', partial: { content: [] } }
yield { type: 'done', message: { content: [], usage: {}, stopReason: 'stop' } }
})()
},
}
const adapter = new ZenAdapter(new ModelCatalog(), { providerOverride: fakeProvider })

// Case 1: user selects Minimal -> reasoning.effort = 'minimal', no root reasoning_effort
const streamMinimal = adapter.stream({
provider: 'opencode2dsh',
model: 'muse-spark-1.3-contributor',
messages: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }],
reasoningEffort: 'minimal',
})
for await (const _ of streamMinimal) {}
const payloadMinimal = capturedOptions.onPayload({ stream: true })
assert.equal(payloadMinimal.reasoning_effort, undefined)
assert.deepEqual(payloadMinimal.reasoning, { effort: 'minimal' })

// Case 2: user selects High -> reasoning.effort = 'high', no root reasoning_effort
const streamHigh = adapter.stream({
provider: 'opencode2dsh',
model: 'muse-spark-1.3-contributor',
messages: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }],
reasoningEffort: 'high',
})
for await (const _ of streamHigh) {}
const payloadHigh = capturedOptions.onPayload({ stream: true })
assert.equal(payloadHigh.reasoning_effort, undefined)
assert.deepEqual(payloadHigh.reasoning, { effort: 'high' })

// Case 3: user selects Off -> clamped to 'minimal' because upstream rejects 'none'
const streamOff = adapter.stream({
provider: 'opencode2dsh',
model: 'muse-spark-1.3-contributor',
messages: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }],
reasoningEffort: 'off',
})
for await (const _ of streamOff) {}
const payloadOff = capturedOptions.onPayload({ stream: true })
assert.equal(payloadOff.reasoning_effort, undefined)
assert.deepEqual(payloadOff.reasoning, { effort: 'minimal' })

// Case 4: user selects default (no reasoningEffort) -> pi-ai's reasoning.effort: 'none' is stripped!
const streamDefault = adapter.stream({
provider: 'opencode2dsh',
model: 'muse-spark-1.3-contributor',
messages: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }],
})
for await (const _ of streamDefault) {}
const payloadDefault = capturedOptions.onPayload({ stream: true, reasoning: { effort: 'none' } })
assert.equal(payloadDefault.reasoning_effort, undefined)
assert.equal(payloadDefault.reasoning, undefined)
})