refactor(web): upgrade apiproxy to the api-gateway service plugin

createApiProxy moves from dsh-host-runtime into dsh-host-apiproxy (the
dependency direction already pointed this way); the package now
default-exports ApiProxyService (config {provider, model}, provides
ctx.apiProxy) while staying transport-agnostic — it registers no routes.
Runtime keeps bootHost/startHost for the headless path with its import
re-anchored, and drops the mountWebPlugins roster mounting helper.
This commit is contained in:
imccyu
2026-07-25 10:22:43 +08:00
parent f7b36bd36d
commit 3cad7f6957
14 changed files with 104 additions and 200 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-apiproxy
The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`.
## Contract layer (`/api`)
@@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-host-apiproxy",
"description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -40,12 +40,16 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"peerDependencies": {
@@ -11,12 +11,14 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
} from './api/index.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
@@ -170,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
}
}
/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */
export interface ApiProxyDefaults {
provider: string
model: string
@@ -272,8 +274,8 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
class SessionNotFound extends Error {}
/**
* Implement ApiProxy over the ctx composed by bootHost.
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
* Implement ApiProxy over a composed host context.
* @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services).
* @param defaults - host-level default provider/model: injected as
* agentOptions on create/resume, reported by describe from the same source.
* @returns the ApiProxy implementation.
+60 -5
View File
@@ -1,13 +1,68 @@
/**
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
* lives in @deepseek-ai/dsh-host-runtime.
* @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
* carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side), and the host-side implementation
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
interface Context {
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
apiProxy: ApiProxy
}
}
/** Gateway plugin config: the host-level default agent routing. */
export interface Config {
/** Default provider route for created/resumed agents. */
provider: string
/** Default model id. */
model: string
}
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The default project
* directory for new sessions is the host process working directory (not a
* config field this round).
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'sessions', 'tools', 'userInteraction']
static Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
})
readonly sessions: ApiProxy['sessions']
readonly host: ApiProxy['host']
readonly events: ApiProxy['events']
readonly respond: ApiProxy['respond']
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() })
this.sessions = api.sessions
this.host = api.host
this.events = api.events
this.respond = api.respond
}
}
export default ApiProxyService
+6 -5
View File
@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this package is the wire contract layer (types,
* schemas, fetch carrier glue) — it emits no cordis events and owns no
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
* enforced at the carrier boundary and exercised by the protocol-isomorphism
* suite; the live implementation relations belong to dsh-host-runtime.
* No runtime invariant: this package is the wire contract layer plus the
* host-side gateway over services owned elsewhere — it emits no cordis events
* of its own; the session/agent event streams it projects are asserted by
* their owning packages' companions. rpcId round-trip and schema acceptance
* are enforced at the carrier boundary and exercised by the
* protocol-isomorphism suite.
*/
const install: InvariantInstaller = () => {}
+15
View File
@@ -8,18 +8,33 @@
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../ui/user-approval"
},
+1 -1
View File
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition).
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
-1
View File
@@ -39,7 +39,6 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
+3 -6
View File
@@ -1,14 +1,11 @@
/**
* @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
* composition (bootHost), the ApiProxy implementation (createApiProxy), and
* the one-step shell seam (startHost). Host-level configuration (defaults,
* persistenceRoot, future user profile) lives here.
* composition (bootHost) and the one-step shell seam (startHost). The ApiProxy
* implementation lives in @deepseek-ai/dsh-host-apiproxy. Host-level
* configuration (defaults, persistenceRoot, future user profile) lives here.
*/
export { bootHost } from './boot.ts'
export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
export { startHost } from './start.ts'
export type { StartHostOptions, RunningHost } from './start.ts'
export { mountWebPlugins } from './web-plugins.ts'
+1 -2
View File
@@ -8,10 +8,9 @@
import type { Context } from 'cordis'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { bootHost } from './boot.ts'
import type { BootHostOptions, HostDefaults } from './boot.ts'
import { createApiProxy } from './api-proxy.ts'
/** Options for startHost. */
export interface StartHostOptions {
-57
View File
@@ -1,57 +0,0 @@
/**
* Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
* entry tree over the caller-supplied client plugin roster. The roster is a
* composition decision and lives in the composing app (apps/cli); this module
* only owns the mount/settle/fail-loud mechanics. The web plugin registry
* discovers fetch-arrival entries among the mounted packages by their
* package.json dshClient declarations; node halves are empty applies, so
* mounting them here costs nothing beyond Loader governance.
*/
import { createRequire } from 'node:module'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
export interface MountedWebPlugins {
/** Entry enumeration surface of the mounted Loader (registry scan source). */
loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> }
/** Resolve a plugin package's package.json absolute path. */
resolvePkgJson: (name: string) => string
}
/**
* Mount the Loader (when absent) and create one in-memory entry per client
* plugin package, then wait for the tree to settle. A plugin whose import
* fails leaves its entry fiber-less — surfaced here as a loud throw listing
* the failures (misconfiguration must not silently drop a client plugin).
* @param ctx - host root context (bootHost product).
* @param plugins - client plugin package names to mount (the composition layer's roster).
* @param anchor - module URL anchoring bare-specifier resolution (the composing
* app's import.meta.url; the roster packages must be dependencies of that app).
* @returns the loader view and package.json resolver the registry consumes.
*/
export async function mountWebPlugins(
ctx: Context, plugins: readonly string[], anchor: string,
): Promise<MountedWebPlugins> {
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
// import silently fails and every entry stays fiber-less. The composing app
// declares the roster packages as dependencies, so its URL is the right anchor.
ctx.baseUrl ??= anchor
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
for (const name of plugins) {
if (!existing.has(name)) await ctx.loader.create({ name })
}
await ctx.loader.await()
const dead = [...ctx.loader.entries()]
.filter(entry => plugins.includes(entry.options.name))
.filter(entry => entry.fiber === undefined && !entry.disabled)
if (dead.length > 0) {
throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
}
const require = createRequire(anchor)
return {
loader: ctx.loader,
resolvePkgJson: name => require.resolve(`${name}/package.json`),
}
}
@@ -16,7 +16,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
@@ -21,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
@@ -1,111 +0,0 @@
/**
* mountWebPlugins unit coverage (keyless). The Loader-facing behavior —
* baseUrl anchoring, entry creation with idempotent reuse, the fiber-less
* fail-loud sweep, and the resolver seam — is exercised against a stubbed
* loader service so it runs without built lib/ artifacts. The roster is
* caller-supplied now (composition moved to apps/cli), so these tests pass
* their own lists.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { mountWebPlugins } from '../src/web-plugins.ts'
const ROSTER = [
'@deepseek-ai/dsh-plugin-a',
'@deepseek-ai/dsh-plugin-b',
'@deepseek-ai/dsh-plugin-c',
] as const
interface FakeEntry {
options: { name: string }
fiber?: unknown
disabled: boolean
}
/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
class FakeLoader {
readonly created: string[] = []
awaited = 0
constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
entries(): Iterable<FakeEntry> {
return this.entriesList
}
async create(options: { name: string }): Promise<void> {
this.created.push(options.name)
this.onCreate?.(options.name)
}
async await(): Promise<void> {
this.awaited += 1
}
}
let root: Context | undefined
afterEach(async () => {
await root?.fiber.dispose()
root = undefined
})
function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
root = new Context()
const loader = new FakeLoader(entriesList, onCreate)
root.reflect.provide('loader', loader)
return { ctx: root, loader }
}
describe('mountWebPlugins (stubbed loader)', () => {
it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => {
const entriesList: FakeEntry[] = []
const { ctx, loader } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url)
expect(loader.created).toEqual([...ROSTER])
expect(loader.awaited).toBe(1)
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER])
// The resolver resolves a real package manifest through real module resolution, anchored at this test file.
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
expect(ctx.baseUrl).toBeDefined()
})
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false }))
const { ctx, loader } = withLoader(preexisting)
await mountWebPlugins(ctx, ROSTER, import.meta.url)
expect(loader.created).toEqual([])
})
it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
// First one loads; the rest stay fiber-less (import failed silently).
entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false })
})
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url))
.rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/)
})
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
const { ctx } = withLoader(entriesList)
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined()
})
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
root = new Context()
// An empty roster keeps this keyless and artifact-free: the branch under
// test is only the Loader auto-mount.
await mountWebPlugins(root, [], import.meta.url)
expect(root.get('loader') !== undefined).toBe(true)
}, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
ctx.baseUrl = 'file:///caller/anchor/'
await mountWebPlugins(ctx, ROSTER, import.meta.url)
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
})
})