Merge pull request #6432 from deepseek-harness/worktree/fix-acp-account-stdout

fix(acp): keep account catalog diagnostics off stdout
This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-10-17 22:23:28 +08:00
commit 9e59ef469f
6 files changed
+110 -7

No files matched your search

@@ -0,0 +1,102 @@
/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */
import { once } from 'node:events'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot'
import { expect, it } from 'vitest'
import { cleanupAcpExampleTest } from './cleanup.ts'
it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-'))
const token = 'acp-catalog-fixture-token'
const requests: Array<{ method: string | undefined; path: string | undefined; token: string | string[] | undefined }> = []
const server = createServer((request, response) => {
requests.push({ method: request.method, path: request.url, token: request.headers['x-dsh-auth-token'] })
request.resume()
if (request.method !== 'GET' || request.url !== '/models') {
response.writeHead(500).end('unexpected request')
return
}
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({
data: [
{
id: 'deepseek-flash', name: 'Account fixture model', input_modalities: ['text'], output_modalities: ['text'],
context_window: 1_048_576, max_output_tokens: 393_216, api_capabilities: { anthropic_messages: {} },
},
{ id: 'deepseek-v4-pro' },
],
}))
})
let launched: LaunchedAcpTestAgent | undefined
try {
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('account catalog server has no port')
const origin = `http://127.0.0.1:${address.port}`
await mkdir(join(cwd, '.dsh'))
await writeFile(join(cwd, '.dsh', '.credentials.yaml'), JSON.stringify({
version: 1,
records: {
'deepseek-account-platform/default': { kind: 'grant', payload: { version: 1, token, issuer: origin } },
},
}) + '\n', { mode: 0o600 })
const configPath = join(cwd, 'catalog.patch.yml')
await writeFile(configPath, JSON.stringify([
{ id: 'deepseek-account', config: { platformOrigin: origin, inferenceOrigin: origin, allowLoopbackHttp: true } },
{ id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic` } },
{ id: 'llm-deepseek', config: { baseURL: `${origin}/anthropic`, models: [{ id: 'fixture-api-key-model' }] } },
// Catalog discovery requires the account service to have loaded the stored grant.
{
id: 'acp', inject: ['acpAppStartup', 'deepseekAccount'],
config: { provider: 'deepseek-official', model: 'fixture-api-key-model' },
},
{ id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } },
]) + '\n')
const agent: AgentUnderTest = {
binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)),
configPath,
profile: 'acp',
tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)),
}
launched = launchAcpTestAgent({
agent,
cwd,
env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: `${origin}/anthropic`, DSH_TELEMETRY_DISABLED: '1' },
})
await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await launched.client.newSession({ cwd, mcpServers: [] })
expect(created.configOptions).toContainEqual(expect.objectContaining({
id: 'model', currentValue: JSON.stringify(['deepseek-official', 'fixture-api-key-model']),
}))
await launched.client.closeSession({ sessionId: created.sessionId })
// Closing joins both pipes; receiving a stdout response alone does not drain stderr.
await launched.close()
expect(launched.child.signalCode).toBeNull()
expect(launched.child.exitCode).toBe(0)
expect(requests).toEqual([{ method: 'GET', path: '/models', token }])
const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(frames.length).toBeGreaterThanOrEqual(3)
for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' })
expect(launched.stderr()).toContain(`[llm-deepseek-account] model listing from ${origin}/models loaded 2 model(s), skipped 0 row(s)`)
expect(launched.stderr()).toContain('[llm-deepseek-account] model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages')
expect(launched.stderr()).not.toContain(token)
} finally {
const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd)])
server.closeAllConnections()
cleanup.push(...await Promise.allSettled([
server.listening ? new Promise<void>((resolve, reject) => server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})) : undefined,
]))
const failures: unknown[] = []
for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason)
if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed')
}
})
@@ -15,8 +15,8 @@
en: 23386452a66b2fbd
zh: 4cf93c4bf17b3da2
/deepseek-ai-dsh-llm-deepseek-account/use-this-package:
en: f0ac16310ce5a5c4
zh: 6929f80d830c18df
en: d6fcd541fcd882ac
zh: c859dff4d6cd36d4
/deepseek-ai-dsh-llm-deepseek-account/understand-the-implementation:
en: 8e8d8e4f786e03c3
zh: 7b32d8cea29d2b1a
+1 -1
View File
@@ -29,7 +29,7 @@ Only `deepseekAccount.resolveToken(url)` supplies the token; the account service
The route's catalog comes from the account's `GET /models` listing at the origin of the Messages `baseURL` (`https://api.deepseek.com/models` for the official endpoint); the listing endpoint is not configurable. Listing requests carry the account token in `x-dsh-auth-token` and the harness `User-Agent`, and reject redirects.
The first access of a credential waits for the listing up to `modelsTimeoutMs`. Later accesses use the loaded listing and refresh it in the background once it is older than `modelsMaxAgeMs`. A failed or empty listing keeps the previous one; before any listing loads, the route serves the built-in catalog of the current version (`deepseek-flash` and `deepseek-v4-pro`). A listing HTTP 401 does not remove the stored login. Each loaded listing, the first failure of a failing streak with the catalog it falls back to, and each hidden listing entry are written to the console with the `[llm-deepseek-account]` tag; secrets are never logged. Each account token has its own listing, so the service can roll a model out to some accounts first.
The first access of a credential waits for the listing up to `modelsTimeoutMs`. Later accesses use the loaded listing and refresh it in the background once it is older than `modelsMaxAgeMs`. A failed or empty listing keeps the previous one; before any listing loads, the route serves the built-in catalog of the current version (`deepseek-flash` and `deepseek-v4-pro`). A listing HTTP 401 does not remove the stored login. Each loaded listing, the first failure of a failing streak with the catalog it falls back to, and each hidden listing entry are written to stderr with the `[llm-deepseek-account]` tag; stdout remains available for protocol messages, and secrets are never logged. Each account token has its own listing, so the service can roll a model out to some accounts first.
A listed model is offered only when the current DeepSeek Harness version can serve it. A model that declares `client_requirements["deepseek-harness"].min_version` is offered when the current version is at least that version in semantic-version order, including prerelease tags such as `0.2.0-alpha.1` and `0.2.0-rc.1`. A prerelease orders below its release: `min_version: 0.2.1` refuses `0.2.1-alpha.2`, so a listing that admits prereleases declares the earliest one it admits, such as `0.2.1-alpha.0`. A model without that declaration is offered only when the built-in catalog knows its id; other listed models stay hidden. A request for a listed model the current version cannot serve fails before network I/O with `CLIENT_UPGRADE_REQUIRED`, which names the required and current versions, or with `MODEL_UNSUPPORTED`. Model ids the listing does not name pass through unchanged.
@@ -29,7 +29,7 @@ kind: "package-reference"
路由的目录来自账号的 `GET /models` 列表,地址为 Messages `baseURL` 的 origin 加 `/models`(官方端点即 `https://api.deepseek.com/models`),不可配置。列表请求用 `x-dsh-auth-token` 携带账号 token,带上 harness 的 `User-Agent`,并拒绝重定向。
同一凭据的第一次访问会等待列表,最长 `modelsTimeoutMs`。之后的访问直接使用已加载的列表,列表超过 `modelsMaxAgeMs` 后在后台刷新。获取失败或得到空列表时保留上一份;还没有加载到任何列表时,路由提供当前版本内置的目录(`deepseek-flash` 与 `deepseek-v4-pro`)。列表请求返回 HTTP 401 不会清除已保存的登录。每次加载成功、连续失败中的第一次失败及其退回的目录、以及每个被隐藏的列表条目,都会以 `[llm-deepseek-account]` 标签写到控制台日志;不记录任何密钥。每个账号 token 有自己的列表,因此服务端可以先对部分账号开放某个模型。
同一凭据的第一次访问会等待列表,最长 `modelsTimeoutMs`。之后的访问直接使用已加载的列表,列表超过 `modelsMaxAgeMs` 后在后台刷新。获取失败或得到空列表时保留上一份;还没有加载到任何列表时,路由提供当前版本内置的目录(`deepseek-flash` 与 `deepseek-v4-pro`)。列表请求返回 HTTP 401 不会清除已保存的登录。每次加载成功、连续失败中的第一次失败及其退回的目录、以及每个被隐藏的列表条目,都会以 `[llm-deepseek-account]` 标签写到 stderr;stdout 保留给协议消息,且不记录任何密钥。每个账号 token 有自己的列表,因此服务端可以先对部分账号开放某个模型。
只有当前版本的 DeepSeek Harness 能用的模型才会出现在目录里。声明了 `client_requirements["deepseek-harness"].min_version` 的模型,在当前版本号按 semver 顺序不低于该版本时提供,预发布号(如 `0.2.0-alpha.1`、`0.2.0-rc.1`)同样参与比较。预发布版本低于同号的正式版本:`min_version: 0.2.1` 会拒绝 `0.2.1-alpha.2`,因此要放行预发布版本的列表应声明它允许的最早一个,如 `0.2.1-alpha.0`。没有这个声明的模型,只有内置目录认识它的 id 时才提供;其余列出的模型保持隐藏。请求一个当前版本不能用的已列出模型时,会在网络请求之前失败:`CLIENT_UPGRADE_REQUIRED` 会写明所需版本和当前版本,否则为 `MODEL_UNSUPPORTED`。列表没有列出的模型 id 照常透传。
@@ -75,7 +75,8 @@ export function apply(ctx: Context, config: Config): void {
token: async url => account()?.resolveToken(url),
load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal),
now: () => Date.now(),
info: (message) => { console.info(LOG_TAG, message) },
// ACP reserves stdout for protocol messages, including during successful catalog loads.
info: (message) => { console.error(LOG_TAG, message) },
warn: (message) => { console.warn(LOG_TAG, message) },
})
let loggedListing: ModelListing | undefined
@@ -109,7 +109,7 @@ it('falls back to the bundled catalog when the listing fails', async () => {
})
it('hides and refuses listed models this build cannot serve before any inference request', async () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const server = await listing(json({ data: [
FLASH,
@@ -127,7 +127,7 @@ it('hides and refuses listed models this build cannot serve before any inference
.rejects.toMatchObject({ code: 'CLIENT_UPGRADE_REQUIRED', message: expect.stringMatching(/requires DeepSeek Harness 99\.0\.0-alpha\.1 or later; current version is /) as string })
await expect(ctx.llm.prepareCall({ provider: 'deepseek-account', model: 'unlisted-pass-through' })).resolves.toBeDefined()
expect(server.requests.map(request => request.url)).toEqual(['/models'])
expect(info).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', expect.stringContaining('loaded 5 model(s), skipped 0 row(s)') as string)
expect(diagnostic).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', expect.stringContaining('loaded 5 model(s), skipped 0 row(s)') as string)
expect(warn).toHaveBeenCalledExactlyOnceWith('[llm-deepseek-account]', 'model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages')
})