|
@@ -7,7 +7,11 @@ import { randomUUID } from 'node:crypto'
|
|
|
import { mkdir, stat } from 'node:fs/promises'
|
|
import { mkdir, stat } from 'node:fs/promises'
|
|
|
import { join } from 'node:path'
|
|
import { join } from 'node:path'
|
|
|
import type { Context } from 'cordis'
|
|
import type { Context } from 'cordis'
|
|
|
-import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
|
|
+import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
+import type {
|
|
|
|
|
+ Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus,
|
|
|
|
|
+} from '@deepseek-ai/dsh-agent'
|
|
|
|
|
+import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
|
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
|
|
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
|
|
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
|
|
@@ -21,7 +25,8 @@ import {
|
|
|
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
|
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
|
import type {
|
|
import type {
|
|
|
- ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
|
|
|
|
|
|
+ ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
|
|
|
|
+ MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
|
|
WorkspaceId, WorkspaceView,
|
|
WorkspaceId, WorkspaceView,
|
|
|
} from './api/index.ts'
|
|
} from './api/index.ts'
|
|
|
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
|
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
|
@@ -361,6 +366,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
|
|
*/
|
|
*/
|
|
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
|
|
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
|
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
|
|
|
|
+ type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
|
|
|
|
|
+ const targets = new WeakMap<Agent, WebLlmTargetRef>()
|
|
|
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
|
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
|
|
const resumes = new Map<SessionId, Promise<Agent>>()
|
|
const resumes = new Map<SessionId, Promise<Agent>>()
|
|
|
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
|
|
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
|
|
@@ -370,6 +377,40 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
|
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
|
|
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
|
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Install or return the session-local target that prompt assembly snapshots.
|
|
|
|
|
+ * Seed order: latest logged request/header, else the host default routing.
|
|
|
|
|
+ * There is no create-time per-session override tier on this wire — if one
|
|
|
|
|
+ * returns (a create-options contribution), it must fold in between the two.
|
|
|
|
|
+ */
|
|
|
|
|
+ function targetFor(agent: Agent): WebLlmTargetRef {
|
|
|
|
|
+ const installed = targets.get(agent)
|
|
|
|
|
+ if (installed !== undefined) return installed
|
|
|
|
|
+ const logged = agent.session.requestHeader()?.config
|
|
|
|
|
+ const target: WebLlmTargetRef = {
|
|
|
|
|
+ current: logged === undefined
|
|
|
|
|
+ ? { provider: defaults.provider, model: defaults.model }
|
|
|
|
|
+ : {
|
|
|
|
|
+ provider: logged.provider,
|
|
|
|
|
+ model: logged.model,
|
|
|
|
|
+ ...logged.reasoningEffort === undefined
|
|
|
|
|
+ ? {}
|
|
|
|
|
+ : { reasoningEffort: logged.reasoningEffort },
|
|
|
|
|
+ },
|
|
|
|
|
+ assembled: undefined,
|
|
|
|
|
+ }
|
|
|
|
|
+ installAgentLlmTarget(agent.ctx, target)
|
|
|
|
|
+ targets.set(agent, target)
|
|
|
|
|
+ return target
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Pre-publication setup used by both fresh and resumed Web agents. */
|
|
|
|
|
+ function installTarget(agentCtx: Context): void {
|
|
|
|
|
+ const agent = agentCtx.agent
|
|
|
|
|
+ if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
|
|
|
|
|
+ targetFor(agent)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** Send one transient frame to every connected mux consumer. */
|
|
/** Send one transient frame to every connected mux consumer. */
|
|
|
function broadcast(payload: MuxFrame): void {
|
|
function broadcast(payload: MuxFrame): void {
|
|
|
const envelope = frame(payload)
|
|
const envelope = frame(payload)
|
|
@@ -493,7 +534,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
resume = (async () => {
|
|
resume = (async () => {
|
|
|
try {
|
|
try {
|
|
|
await assertServable(sessionId)
|
|
await assertServable(sessionId)
|
|
|
- const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
|
|
|
|
|
|
|
+ const handle = await ctx.agents.resume({
|
|
|
|
|
+ resumeSessionId: sessionId,
|
|
|
|
|
+ agentOptions,
|
|
|
|
|
+ setup: installTarget,
|
|
|
|
|
+ })
|
|
|
return handle.agent
|
|
return handle.agent
|
|
|
} finally {
|
|
} finally {
|
|
|
resumes.delete(sessionId)
|
|
resumes.delete(sessionId)
|
|
@@ -528,7 +573,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
if (stored.cwd !== cwd) {
|
|
if (stored.cwd !== cwd) {
|
|
|
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
|
|
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
|
|
|
}
|
|
}
|
|
|
- return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
|
|
|
|
|
|
|
+ return (await ctx.agents.resume({
|
|
|
|
|
+ resumeSessionId: sessionId,
|
|
|
|
|
+ agentOptions,
|
|
|
|
|
+ setup: installTarget,
|
|
|
|
|
+ })).agent
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
@@ -536,7 +585,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
} catch (error: unknown) {
|
|
} catch (error: unknown) {
|
|
|
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
|
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
|
|
}
|
|
}
|
|
|
- return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
|
|
|
|
|
|
|
+ return (await ctx.agents.create({
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ agentOptions,
|
|
|
|
|
+ meta: { cwd },
|
|
|
|
|
+ setup: installTarget,
|
|
|
|
|
+ })).agent
|
|
|
})().catch((error: unknown) => {
|
|
})().catch((error: unknown) => {
|
|
|
// Another Host entry path may have published the same identity while
|
|
// Another Host entry path may have published the same identity while
|
|
|
// this operation crossed an asynchronous persistence/filesystem step.
|
|
// this operation crossed an asynchronous persistence/filesystem step.
|
|
@@ -672,6 +726,107 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } })
|
|
return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } })
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
|
|
+ async models(request) {
|
|
|
|
|
+ const { sessionId } = request.payload
|
|
|
|
|
+ const found = await agentFor(sessionId)
|
|
|
|
|
+ if ('error' in found) return err(request, found.error)
|
|
|
|
|
+ const current = targetFor(found.agent).current
|
|
|
|
|
+ const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const advertised = await ctx.llm.listModels(provider.id)
|
|
|
|
|
+ const models = [...advertised]
|
|
|
|
|
+ if (
|
|
|
|
|
+ provider.id === current.provider
|
|
|
|
|
+ && !models.some(model => model.id === current.model)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ models.push({
|
|
|
|
|
+ provider: provider.id,
|
|
|
|
|
+ id: current.model,
|
|
|
|
|
+ name: current.model,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ const entries = await Promise.all(models.map(async (model) => {
|
|
|
|
|
+ const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
|
|
|
|
+ const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
|
|
|
|
+ ? undefined
|
|
|
|
|
+ : {
|
|
|
|
|
+ efforts: resolved.reasoning.efforts.map(effort => ({
|
|
|
|
|
+ id: effort.id,
|
|
|
|
|
+ name: effort.name,
|
|
|
|
|
+ ...effort.description === undefined
|
|
|
|
|
+ ? {}
|
|
|
|
|
+ : { description: effort.description },
|
|
|
|
|
+ })),
|
|
|
|
|
+ ...resolved.reasoning.defaultEffort === undefined
|
|
|
|
|
+ ? {}
|
|
|
|
|
+ : { defaultEffort: resolved.reasoning.defaultEffort },
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: model.id,
|
|
|
|
|
+ name: model.name,
|
|
|
|
|
+ ...model.description === undefined ? {} : { description: model.description },
|
|
|
|
|
+ ...provider.id === current.provider
|
|
|
|
|
+ && model.id === current.model
|
|
|
|
|
+ && !advertised.some(candidate => candidate.id === current.model)
|
|
|
|
|
+ ? { unlisted: true as const }
|
|
|
|
|
+ : {},
|
|
|
|
|
+ ...reasoning === undefined ? {} : { reasoning },
|
|
|
|
|
+ }
|
|
|
|
|
+ }))
|
|
|
|
|
+ const group: ModelProviderGroup = {
|
|
|
|
|
+ id: provider.id,
|
|
|
|
|
+ name: provider.name,
|
|
|
|
|
+ models: entries,
|
|
|
|
|
+ }
|
|
|
|
|
+ return { kind: 'group' as const, group }
|
|
|
|
|
+ } catch (error: unknown) {
|
|
|
|
|
+ const failure: ModelCatalogFailure = {
|
|
|
|
|
+ id: provider.id,
|
|
|
|
|
+ name: provider.name,
|
|
|
|
|
+ message: error instanceof Error ? error.message : String(error),
|
|
|
|
|
+ }
|
|
|
|
|
+ return { kind: 'failure' as const, failure }
|
|
|
|
|
+ }
|
|
|
|
|
+ }))
|
|
|
|
|
+ const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
|
|
|
|
|
+ const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
|
|
|
|
|
+ return ok(request, {
|
|
|
|
|
+ current: { ...current },
|
|
|
|
|
+ groups: groups.filter(group => group.models.length > 0),
|
|
|
|
|
+ failures,
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ async selectModel(request) {
|
|
|
|
|
+ const { sessionId, provider, model, reasoningEffort } = request.payload
|
|
|
|
|
+ const found = await agentFor(sessionId)
|
|
|
|
|
+ if ('error' in found) return err(request, found.error)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const resolved = await ctx.llm.resolveCallConfig({
|
|
|
|
|
+ provider,
|
|
|
|
|
+ model,
|
|
|
|
|
+ ...reasoningEffort === undefined
|
|
|
|
|
+ ? {}
|
|
|
|
|
+ : { reasoningEffort: ReasoningEffortId(reasoningEffort) },
|
|
|
|
|
+ })
|
|
|
|
|
+ const selected: AgentLlmTarget = {
|
|
|
|
|
+ provider: resolved.provider,
|
|
|
|
|
+ model: resolved.model,
|
|
|
|
|
+ ...resolved.reasoningEffort === undefined
|
|
|
|
|
+ ? {}
|
|
|
|
|
+ : { reasoningEffort: resolved.reasoningEffort },
|
|
|
|
|
+ }
|
|
|
|
|
+ targetFor(found.agent).current = selected
|
|
|
|
|
+ return ok(request, { selected: { ...selected } })
|
|
|
|
|
+ } catch (error: unknown) {
|
|
|
|
|
+ return err(request, {
|
|
|
|
|
+ code: 'model-unavailable',
|
|
|
|
|
+ message: error instanceof Error ? error.message : String(error),
|
|
|
|
|
+ details: { provider, model },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
async prompt(request) {
|
|
async prompt(request) {
|
|
|
const { sessionId, mode, content } = request.payload
|
|
const { sessionId, mode, content } = request.payload
|
|
|
const found = await agentFor(sessionId)
|
|
const found = await agentFor(sessionId)
|