fix: resolve apiProxy via ctx.get for /model (v0.3.2)

Cordis throws on ctx.apiProxy without inject; use ctx.get and declare apiProxy inject so catalog and model APIs work under dsh web.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 20:20:55 +08:00
co-authored by Cursor
parent f433cce24d
commit 5dbe833205
18 changed files with 209 additions and 35 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { Context } from '@deepseek-ai/cordis'
/** Loose ApiProxy surface used by catalog + model helpers. */
export type ApiProxySessions = {
list?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
models?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
selectModel?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
}
export type ApiProxyLike = {
workspace?: {
list?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
}
sessions?: ApiProxySessions
}
/**
* Resolve host apiProxy without requiring Cordis inject.
* Direct `ctx.apiProxy` throws "cannot get property without inject" on real fibers.
*/
export function resolveApiProxy(ctx: Context): ApiProxyLike | undefined {
const c = ctx as Context & {
get?: (name: string, strict?: boolean) => unknown
apiProxy?: ApiProxyLike
}
if (typeof c.get === 'function') {
try {
const viaGet = c.get('apiProxy') as ApiProxyLike | undefined
if (viaGet) return viaGet
} catch {
// ignore and try own-property fallback (tests / plain mocks)
}
}
try {
// Own property on plain mocks only — Cordis proxy throws without inject.
if (Object.prototype.hasOwnProperty.call(c, 'apiProxy') && c.apiProxy) {
return c.apiProxy
}
} catch {
return undefined
}
return undefined
}
+2 -1
View File
@@ -432,7 +432,8 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, body, undefined, keyboard)
} catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`)
await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE)
const detail = err instanceof Error ? err.message : String(err)
await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE(detail))
}
}
+2 -6
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { resolveApiProxy } from './apiproxy.js'
import { describeAgent, workspaceName } from './label.js'
export interface WorkspaceRow {
@@ -59,12 +60,7 @@ function titleOf(summary: {
/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */
export async function loadCatalog(ctx: Context): Promise<CatalogSnapshot | undefined> {
const api = (ctx as {
apiProxy?: {
workspace?: { list?: ApiFn }
sessions?: { list?: ApiFn }
}
}).apiProxy
const api = resolveApiProxy(ctx)
const wsPromise = rpcCall(api?.workspace?.list, {})
const sessPromise = rpcCall(api?.sessions?.list, {})
+5 -1
View File
@@ -36,7 +36,11 @@ export const MSG = {
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
},
GONE: '绑定的会话已不可用。请重新 /sessions。',
MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。',
MODEL_UNAVAILABLE(detail?: string): string {
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。'
if (!detail) return tip
return `${tip}\n详情:${detail}`
},
MODEL_UNROUTABLE(current: string): string {
return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。`
},
+2 -1
View File
@@ -3,7 +3,8 @@ import Schema from '@deepseek-ai/schemastery'
import { TelegramBridge } from './bridge.js'
export const name = 'dsh-telegram-channel'
export const inject = ['agents']
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
export const inject = ['agents', 'apiProxy']
export interface TelegramChannelConfig {
token?: string
+28 -11
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import { resolveApiProxy } from './apiproxy.js'
export interface ModelOption {
provider: string
@@ -17,21 +18,32 @@ export interface ModelSnapshot {
type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise<unknown>
function unwrap<T>(res: unknown): T | undefined {
const r = res as { result?: { ok?: boolean; value?: T } } | undefined
const r = res as { result?: { ok?: boolean; value?: T; error?: { message?: string } } } | undefined
if (r?.result?.ok === true) return r.result.value
return undefined
}
function unwrapError(res: unknown): string | undefined {
const r = res as { result?: { ok?: boolean; error?: { message?: string; code?: string } } } | undefined
if (r?.result && r.result.ok === false) {
const err = r.result.error
if (!err) return 'rpc failed'
return err.code ? `${err.code}: ${err.message ?? ''}`.trim() : (err.message ?? 'rpc failed')
}
return undefined
}
async function call(fn: ApiFn | undefined, payload: unknown): Promise<unknown> {
if (typeof fn !== 'function') throw new Error('apiProxy sessions API unavailable')
return fn({ rpcId: randomUUID(), payload })
}
export async function loadSessionModels(ctx: Context, sessionId: string): Promise<ModelSnapshot> {
const api = (ctx as {
apiProxy?: { sessions?: { models?: ApiFn } }
}).apiProxy
const raw = await call(api?.sessions?.models, { sessionId })
const api = resolveApiProxy(ctx)
if (!api?.sessions?.models) {
throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)')
}
const raw = await call(api.sessions.models, { sessionId })
const value = unwrap<{
current: { provider: string; model: string; reasoningEffort?: string }
routable: boolean
@@ -45,7 +57,9 @@ export async function loadSessionModels(ctx: Context, sessionId: string): Promis
}>
}>
}>(raw)
if (!value) throw new Error('failed to load session models')
if (!value) {
throw new Error(unwrapError(raw) ?? 'failed to load session models')
}
const options: ModelOption[] = []
for (const group of value.groups ?? []) {
@@ -71,17 +85,20 @@ export async function selectSessionModel(
sessionId: string,
selection: { provider: string; model: string; reasoningEffort?: string },
): Promise<{ provider: string; model: string; reasoningEffort?: string }> {
const api = (ctx as {
apiProxy?: { sessions?: { selectModel?: ApiFn } }
}).apiProxy
const raw = await call(api?.sessions?.selectModel, {
const api = resolveApiProxy(ctx)
if (!api?.sessions?.selectModel) {
throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)')
}
const raw = await call(api.sessions.selectModel, {
sessionId,
provider: selection.provider,
model: selection.model,
reasoningEffort: selection.reasoningEffort,
})
const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw)
if (!value?.selected) throw new Error('failed to select model')
if (!value?.selected) {
throw new Error(unwrapError(raw) ?? 'failed to select model')
}
return value.selected
}