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:
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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user