diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts new file mode 100644 index 0000000000..9468f874cc --- /dev/null +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -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) + 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((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') + } +}) diff --git a/packages/llm/llm-deepseek-account/README.i18n.yaml b/packages/llm/llm-deepseek-account/README.i18n.yaml index 355018a3c4..a1482909bd 100644 --- a/packages/llm/llm-deepseek-account/README.i18n.yaml +++ b/packages/llm/llm-deepseek-account/README.i18n.yaml @@ -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 diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md index 223640859b..6ceafc3bc9 100644 --- a/packages/llm/llm-deepseek-account/README.md +++ b/packages/llm/llm-deepseek-account/README.md @@ -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. diff --git a/packages/llm/llm-deepseek-account/README.zh.md b/packages/llm/llm-deepseek-account/README.zh.md index 382c09a908..09954ce198 100644 --- a/packages/llm/llm-deepseek-account/README.zh.md +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -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 照常透传。 diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 4303a0e407..79feb7994c 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -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 diff --git a/packages/llm/llm-deepseek-account/tests/provider.spec.ts b/packages/llm/llm-deepseek-account/tests/provider.spec.ts index 7a0dc604df..d4a75057d2 100644 --- a/packages/llm/llm-deepseek-account/tests/provider.spec.ts +++ b/packages/llm/llm-deepseek-account/tests/provider.spec.ts @@ -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') })