diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f2e12..7e77177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0944973..264caa4 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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", diff --git a/packages/plugin/src/adapter/messages.ts b/packages/plugin/src/adapter/messages.ts index 3918515..77477fb 100644 --- a/packages/plugin/src/adapter/messages.ts +++ b/packages/plugin/src/adapter/messages.ts @@ -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 @@ -120,12 +120,14 @@ function toPiAssistant(message: HarnessMessage, providerId: string): Extract block.type === 'toolCall') ? 'toolUse' : 'stop', timestamp: 0, diff --git a/packages/plugin/src/adapter/zen-adapter.ts b/packages/plugin/src/adapter/zen-adapter.ts index d14a2ff..bf86073 100644 --- a/packages/plugin/src/adapter/zen-adapter.ts +++ b/packages/plugin/src/adapter/zen-adapter.ts @@ -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' @@ -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 @@ -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): PiEvent { return { type: 'error', error: { - api: 'openai-completions', + api: model.api, provider: PROVIDER_ID, model: model.id, content: [], @@ -130,10 +147,11 @@ function terminalErrorEvent(errorMessage: string, model: Model): PiEvent { } function toPiModel(id: string, reasoning: boolean): Model { + 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 @@ -141,6 +159,7 @@ function toPiModel(id: string, reasoning: boolean): Model { // 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, @@ -153,6 +172,7 @@ 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 @@ -160,10 +180,12 @@ export class ZenAdapter { /** 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 @@ -180,7 +202,10 @@ export class ZenAdapter { }, }, models: [], - api: openaiCompletions, + api: { + 'openai-completions': openaiCompletions, + 'openai-responses': openaiResponses, + }, }) } @@ -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, () => @@ -433,6 +460,7 @@ export class ZenAdapter { ids: ReturnType, model: ReturnType, ): 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 @@ -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), 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) } as Record & { + 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, diff --git a/packages/plugin/test/zen-adapter.test.ts b/packages/plugin/test/zen-adapter.test.ts index f05b0bb..a70b85d 100644 --- a/packages/plugin/test/zen-adapter.test.ts +++ b/packages/plugin/test/zen-adapter.test.ts @@ -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 @@ -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) +})