瀏覽代碼

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.
imccyu 2 月之前
父節點
當前提交
3cad7f6957

+ 2 - 2
packages/host/apiproxy/README.md

@@ -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
packages/host/apiproxy/package.json

@@ -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": {

+ 9 - 7
packages/host/runtime/src/api-proxy.ts → packages/host/apiproxy/src/api-proxy.ts

@@ -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
packages/host/apiproxy/src/index.ts

@@ -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
packages/host/apiproxy/src/invariant.ts

@@ -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 - 0
packages/host/apiproxy/tsconfig.json

@@ -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
packages/host/runtime/README.md

@@ -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.
 

+ 0 - 1
packages/host/runtime/package.json

@@ -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
packages/host/runtime/src/index.ts

@@ -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
packages/host/runtime/src/start.ts

@@ -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 {

+ 0 - 57
packages/host/runtime/src/web-plugins.ts

@@ -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`),
-  }
-}

+ 1 - 1
packages/host/runtime/tests/api-proxy-cold.spec.ts

@@ -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
 

+ 1 - 1
packages/host/runtime/tests/api-proxy-view.spec.ts

@@ -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 }])
 

+ 0 - 111
packages/host/runtime/tests/web-plugins.spec.ts

@@ -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/')
-  })
-})