Merge PR1 DeepSeek e2e into Claude provider
# Conflicts: # .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml # .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md # .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md
|
||||
README.md: e19b119e355953a388ec4fca6a2db511e7eb43da
|
||||
README.zh.md: 6d3b8719329691681582abd91b82abd167e89f6d
|
||||
README.md: 59d7dcd8406cb244854bbb704f644af1a6aa8589
|
||||
README.zh.md: 90ea0a316b91da3249b934984a3f512b71b8b3b2
|
||||
|
||||
@@ -51,7 +51,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe
|
||||
|
||||
## Product compatibility and evidence
|
||||
|
||||
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. The real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts.
|
||||
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. The keyless real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts. A credentialed e2e maps a runtime-only DeepSeek key to the official Claude Code environment contract, starts the production provider and real SDK/CLI against the fixed official `/anthropic` endpoint, requires a unique nonce, and proves process-tree exit without persisting the key or calling the Messages API directly from the test.
|
||||
|
||||
The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
||||
|
||||
## 产品兼容性与证据
|
||||
|
||||
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。
|
||||
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。无密钥真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。带密钥 e2e 会将仅在运行时提供的 DeepSeek 密钥映射到 Claude Code 官方环境契约,启动生产提供方与真实 SDK 和 CLI 并连接固定的官方 `/anthropic` 端点,要求获得一个唯一随机数,并在不持久化密钥且不由测试直接调用 Messages API 的情况下证明进程树退出。
|
||||
|
||||
项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as claudeCode from '../src/index.ts'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
|
||||
const sdkRoot = dirname(fileURLToPath(
|
||||
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
|
||||
))
|
||||
const sdkPackage = JSON.parse(readFileSync(
|
||||
join(sdkRoot, 'package.json'),
|
||||
'utf8',
|
||||
)) as {
|
||||
version: string
|
||||
claudeCodeVersion: string
|
||||
optionalDependencies: Record<string, string>
|
||||
}
|
||||
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
|
||||
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
|
||||
const claudeBin = join(
|
||||
platformRoot,
|
||||
process.platform === 'win32' ? 'claude.exe' : 'claude',
|
||||
)
|
||||
|
||||
const roots: string[] = []
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function deepSeekBaseUrl(): string {
|
||||
const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL)
|
||||
.replace(/\/+$/, '')
|
||||
if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) {
|
||||
throw new Error('Claude Code DeepSeek e2e requires the official DeepSeek base URL')
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
|
||||
expect(handles.length).toBeGreaterThan(0)
|
||||
for (const handle of handles) {
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
await expect(handle.done).resolves.toHaveProperty('exitCode')
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
|
||||
'Claude Code provider with real DeepSeek API',
|
||||
() => {
|
||||
it('returns one unique nonce through the production provider and real SDK/CLI', async () => {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-deepseek-e2e-'))
|
||||
roots.push(root)
|
||||
const workspace = join(root, 'workspace')
|
||||
const claudeConfig = join(root, 'claude-config')
|
||||
const xdgConfig = join(root, 'xdg-config')
|
||||
const xdgCache = join(root, 'xdg-cache')
|
||||
const xdgData = join(root, 'xdg-data')
|
||||
const xdgState = join(root, 'xdg-state')
|
||||
for (const directory of [
|
||||
workspace,
|
||||
claudeConfig,
|
||||
xdgConfig,
|
||||
xdgCache,
|
||||
xdgData,
|
||||
xdgState,
|
||||
]) mkdirSync(directory)
|
||||
|
||||
const env = {
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`,
|
||||
ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'deepseek-v4-pro[1m]',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v4-pro[1m]',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'deepseek-v4-flash',
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: 'deepseek-v4-flash',
|
||||
CLAUDE_CODE_EFFORT_LEVEL: 'max',
|
||||
CLAUDE_CONFIG_DIR: claudeConfig,
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: xdgConfig,
|
||||
XDG_CACHE_HOME: xdgCache,
|
||||
XDG_DATA_HOME: xdgData,
|
||||
XDG_STATE_HOME: xdgState,
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
|
||||
DISABLE_TELEMETRY: '1',
|
||||
DISABLE_ERROR_REPORTING: '1',
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
NO_PROXY: '127.0.0.1,localhost',
|
||||
}
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const handles: SubprocessHandle[] = []
|
||||
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = spawn(spec)
|
||||
handles.push(handle)
|
||||
return handle
|
||||
})
|
||||
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
|
||||
|
||||
expect(sdkPackage.version).toBe('0.3.220')
|
||||
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
|
||||
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
|
||||
const version = await execFileAsync(claudeBin, ['--version'], {
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
|
||||
|
||||
const nonce = `DSH_CLAUDE_DEEPSEEK_${randomUUID()}`
|
||||
const parent = {
|
||||
id: 'deepseek-e2e-parent',
|
||||
session: { header: { cwd: workspace } },
|
||||
} as unknown as Agent
|
||||
const run = await ctx.subagents.start('claude-code', {
|
||||
prompt: [{
|
||||
type: 'text',
|
||||
text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`,
|
||||
}],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
const text = result.output
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.trim()
|
||||
expect(text).toBe(nonce)
|
||||
await expectQuiescent(handles)
|
||||
}, 180_000)
|
||||
},
|
||||
)
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md
|
||||
README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8
|
||||
README.zh.md: 110953312162e146f01ef037a40d2f70b136850c
|
||||
README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece
|
||||
README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f
|
||||
|
||||
@@ -47,7 +47,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe
|
||||
|
||||
## Product compatibility and evidence
|
||||
|
||||
The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`.
|
||||
The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -83,7 +83,7 @@ Append-only: the new tool result follows the reusable parent request prefix.
|
||||
|
||||
- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
|
||||
- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
|
||||
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests.
|
||||
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
|
||||
- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package.
|
||||
- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
|
||||
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## 产品兼容性与证据
|
||||
|
||||
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。
|
||||
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -83,7 +83,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这
|
||||
|
||||
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
|
||||
- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
|
||||
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。
|
||||
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
|
||||
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
|
||||
- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
|
||||
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type {
|
||||
IncomingMessage,
|
||||
Server,
|
||||
ServerResponse,
|
||||
} from 'node:http'
|
||||
import { completeResponsesEvents } from './responses-fixture.ts'
|
||||
|
||||
const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
|
||||
const MAX_REQUEST_BYTES = 1_048_576
|
||||
|
||||
/** One running test-only Responses-to-DeepSeek bridge. */
|
||||
export interface DeepSeekResponsesBridge {
|
||||
readonly baseUrl: string
|
||||
readonly completedRequests: number
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function readRequest(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => {
|
||||
body += chunk
|
||||
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) {
|
||||
request.destroy(new Error('DeepSeek bridge request exceeded its byte limit'))
|
||||
}
|
||||
})
|
||||
request.on('end', () => { resolve(body) })
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function responseInputTexts(body: Record<string, unknown>): string[] {
|
||||
if (!Array.isArray(body.input)) return []
|
||||
return body.input.flatMap((item): string[] => {
|
||||
if (item === null || typeof item !== 'object') return []
|
||||
const content = (item as Record<string, unknown>).content
|
||||
if (!Array.isArray(content)) return []
|
||||
return content.flatMap((part): string[] => (
|
||||
part !== null
|
||||
&& typeof part === 'object'
|
||||
&& typeof (part as Record<string, unknown>).text === 'string'
|
||||
? [(part as Record<string, unknown>).text as string]
|
||||
: []
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
function taskText(body: Record<string, unknown>): string {
|
||||
const input = responseInputTexts(body).join('\n')
|
||||
if (input.trim().length > 0) return input
|
||||
return typeof body.instructions === 'string' ? body.instructions : ''
|
||||
}
|
||||
|
||||
function deepSeekBaseUrl(): string {
|
||||
const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL)
|
||||
.replace(/\/+$/, '')
|
||||
if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) {
|
||||
throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL')
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
async function completeWithDeepSeek(
|
||||
authorization: string,
|
||||
task: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Follow the user instruction and return only the requested nonce.',
|
||||
},
|
||||
{ role: 'user', content: task },
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 64,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
void response.body?.cancel()
|
||||
throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`)
|
||||
}
|
||||
const payload = await response.json() as {
|
||||
choices?: Array<{ message?: { content?: unknown } }>
|
||||
}
|
||||
const content = payload.choices?.[0]?.message?.content
|
||||
if (typeof content !== 'string' || content.trim().length === 0) {
|
||||
throw new Error('DeepSeek bridge upstream returned no text')
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error !== undefined) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
server.closeAllConnections()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the single-purpose loopback bridge used by the Codex credentialed e2e.
|
||||
* @param nonce - unique answer the incoming Responses task must request.
|
||||
* @returns loopback endpoint, completion count, and close operation.
|
||||
*/
|
||||
export async function startDeepSeekResponsesBridge(
|
||||
nonce: string,
|
||||
): Promise<DeepSeekResponsesBridge> {
|
||||
let seenRequests = 0
|
||||
let completedRequests = 0
|
||||
const openResponses = new Set<ServerResponse>()
|
||||
const server = createServer((request, response) => {
|
||||
openResponses.add(response)
|
||||
response.on('close', () => { openResponses.delete(response) })
|
||||
void (async () => {
|
||||
if (request.method !== 'POST' || request.url !== '/v1/responses') {
|
||||
response.writeHead(404)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (seenRequests !== 0) {
|
||||
response.writeHead(409)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
seenRequests += 1
|
||||
const authorization = request.headers.authorization
|
||||
if (
|
||||
typeof authorization !== 'string'
|
||||
|| !authorization.startsWith('Bearer ')
|
||||
|| authorization.length === 'Bearer '.length
|
||||
) {
|
||||
throw new Error('Codex DeepSeek bridge received no bearer credential')
|
||||
}
|
||||
const body = JSON.parse(await readRequest(request)) as Record<string, unknown>
|
||||
const task = taskText(body)
|
||||
if (!task.includes(nonce)) {
|
||||
throw new Error('Codex DeepSeek bridge request omitted the expected nonce')
|
||||
}
|
||||
const text = await completeWithDeepSeek(authorization, task)
|
||||
completedRequests += 1
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
'x-request-id': 'req_deepseek_e2e',
|
||||
})
|
||||
for (const event of completeResponsesEvents(text)) {
|
||||
response.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(502, { 'content-type': 'application/json' })
|
||||
}
|
||||
response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } }))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('DeepSeek bridge did not acquire a TCP port')
|
||||
}
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
get completedRequests(): number { return completedRequests },
|
||||
async close(): Promise<void> {
|
||||
for (const response of openResponses) response.destroy()
|
||||
await closeServer(server)
|
||||
},
|
||||
}
|
||||
}
|
||||
141
packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts
Normal file
141
packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as codex from '../src/index.ts'
|
||||
import {
|
||||
startDeepSeekResponsesBridge,
|
||||
type DeepSeekResponsesBridge,
|
||||
} from './deepseek-responses-bridge.ts'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
|
||||
const codexPackage = JSON.parse(readFileSync(
|
||||
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
|
||||
'utf8',
|
||||
)) as { version: string }
|
||||
|
||||
const roots: string[] = []
|
||||
const contexts: Context[] = []
|
||||
const bridges: DeepSeekResponsesBridge[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(bridges.splice(0).map(bridge => bridge.close()))
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
|
||||
expect(handles.length).toBeGreaterThan(0)
|
||||
for (const handle of handles) {
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
await expect(handle.done).resolves.toHaveProperty('exitCode')
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
|
||||
'Codex provider with real DeepSeek API',
|
||||
() => {
|
||||
it('returns one unique nonce through the production provider and real Codex', async () => {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-'))
|
||||
roots.push(root)
|
||||
const workspace = join(root, 'workspace')
|
||||
const codexHome = join(root, 'codex-home')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(codexHome)
|
||||
const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}`
|
||||
const bridge = await startDeepSeekResponsesBridge(nonce)
|
||||
bridges.push(bridge)
|
||||
writeFileSync(join(codexHome, 'config.toml'), [
|
||||
'model = "deepseek-v4-flash"',
|
||||
'model_provider = "deepseek-e2e"',
|
||||
'approval_policy = "never"',
|
||||
'sandbox_mode = "read-only"',
|
||||
'disable_response_storage = true',
|
||||
'check_for_update_on_startup = false',
|
||||
'',
|
||||
'[model_providers.deepseek-e2e]',
|
||||
'name = "DeepSeek E2E bridge"',
|
||||
`base_url = "${bridge.baseUrl}"`,
|
||||
'env_key = "DEEPSEEK_API_KEY"',
|
||||
'wire_api = "responses"',
|
||||
'requires_openai_auth = false',
|
||||
'',
|
||||
'[analytics]',
|
||||
'enabled = false',
|
||||
'',
|
||||
].join('\n'))
|
||||
const env = {
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: join(root, 'xdg-config'),
|
||||
PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`,
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
NO_PROXY: '127.0.0.1,localhost',
|
||||
}
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const handles: SubprocessHandle[] = []
|
||||
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = spawn(spec)
|
||||
handles.push(handle)
|
||||
return handle
|
||||
})
|
||||
await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
|
||||
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
expect(codexPackage.version).toBe('0.146.0')
|
||||
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
|
||||
|
||||
const parent = {
|
||||
id: 'deepseek-e2e-parent',
|
||||
session: { header: { cwd: workspace } },
|
||||
} as unknown as Agent
|
||||
const run = await ctx.subagents.start('codex', {
|
||||
prompt: [{
|
||||
type: 'text',
|
||||
text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`,
|
||||
}],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
const text = result.output
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.trim()
|
||||
expect(text).toBe(nonce)
|
||||
expect(bridge.completedRequests).toBe(1)
|
||||
await expectQuiescent(handles)
|
||||
}, 180_000)
|
||||
},
|
||||
)
|
||||
@@ -85,7 +85,12 @@ function responseObject(text: string): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function completeEvents(text: string): Record<string, unknown>[] {
|
||||
/**
|
||||
* Build the minimal Responses SSE event sequence consumed by Codex 0.146.0.
|
||||
* @param text - exact assistant answer.
|
||||
* @returns ordered response lifecycle events.
|
||||
*/
|
||||
export function completeResponsesEvents(text: string): Record<string, unknown>[] {
|
||||
const completed = responseObject(text)
|
||||
const message = (completed.output as Record<string, unknown>[])[0]!
|
||||
const part = (message.content as Record<string, unknown>[])[0]!
|
||||
@@ -250,7 +255,7 @@ export async function startResponsesFixture(
|
||||
})
|
||||
if (behavior.kind === 'hold') return
|
||||
const events = behavior.kind === 'complete'
|
||||
? completeEvents(behavior.text)
|
||||
? completeResponsesEvents(behavior.text)
|
||||
: functionCallEvents(behavior.name, behavior.arguments)
|
||||
for (const event of events) {
|
||||
response.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
|
||||
Reference in New Issue
Block a user