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:
@@ -188,7 +188,7 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel
|
||||
| 「无权限」 | User ID 必须是 `@userinfobot` 的数字 |
|
||||
| `/sessions` 无会话 | 确认 Web 有未归档会话;空白会话会被隐藏 |
|
||||
| `/sessions` 比电脑少很多 | 升级到 ≥0.3.0:应按工作区列出;仍少则检查是否归档 |
|
||||
| `/model` 不可用 | 需 `dsh web`(apiProxy);先 `/sessions` 绑定 |
|
||||
| `/model` 不可用 | 需 `dsh web`(apiProxy);先 `/sessions` 绑定。≥0.3.2 已修复「未 inject 读不到 apiProxy」 |
|
||||
| Telegram 401 | Token 错了或被 revoke |
|
||||
|
||||
---
|
||||
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
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 declare function resolveApiProxy(ctx: Context): ApiProxyLike | undefined;
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Resolve host apiProxy without requiring Cordis inject.
|
||||
* Direct `ctx.apiProxy` throws "cannot get property without inject" on real fibers.
|
||||
*/
|
||||
export function resolveApiProxy(ctx) {
|
||||
const c = ctx;
|
||||
if (typeof c.get === 'function') {
|
||||
try {
|
||||
const viaGet = c.get('apiProxy');
|
||||
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
@@ -363,7 +363,8 @@ export class TelegramBridge {
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
async sendEffortPicker(chatId, option) {
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolveApiProxy } from './apiproxy.js';
|
||||
import { describeAgent, workspaceName } from './label.js';
|
||||
function rpcCall(fn, payload) {
|
||||
if (typeof fn !== 'function')
|
||||
@@ -27,7 +28,7 @@ function titleOf(summary) {
|
||||
}
|
||||
/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */
|
||||
export async function loadCatalog(ctx) {
|
||||
const api = ctx.apiProxy;
|
||||
const api = resolveApiProxy(ctx);
|
||||
const wsPromise = rpcCall(api?.workspace?.list, {});
|
||||
const sessPromise = rpcCall(api?.sessions?.list, {});
|
||||
if (!wsPromise || !sessPromise)
|
||||
|
||||
Vendored
+1
-1
@@ -15,7 +15,7 @@ export declare const MSG: {
|
||||
readonly STATUS_BOUND: (label: string) => string;
|
||||
readonly STATUS_BOUND_COLD: (label: string) => string;
|
||||
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
|
||||
readonly MODEL_UNAVAILABLE: "无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。";
|
||||
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
|
||||
readonly MODEL_UNROUTABLE: (current: string) => string;
|
||||
readonly MODEL_EMPTY: (current: string) => string;
|
||||
readonly MODEL_SET: (selected: string) => string;
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ export const MSG = {
|
||||
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
|
||||
},
|
||||
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||
MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。',
|
||||
MODEL_UNAVAILABLE(detail) {
|
||||
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。';
|
||||
if (!detail)
|
||||
return tip;
|
||||
return `${tip}\n详情:${detail}`;
|
||||
},
|
||||
MODEL_UNROUTABLE(current) {
|
||||
return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。`;
|
||||
},
|
||||
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis';
|
||||
import Schema from '@deepseek-ai/schemastery';
|
||||
export declare const name = "dsh-telegram-channel";
|
||||
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
|
||||
export declare const inject: string[];
|
||||
export interface TelegramChannelConfig {
|
||||
token?: string;
|
||||
|
||||
+2
-1
@@ -1,7 +1,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 const Config = Schema.object({
|
||||
token: Schema.string().default(''),
|
||||
allowedUserIds: Schema.array(Schema.number()).default([]),
|
||||
|
||||
+27
-8
@@ -1,21 +1,36 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolveApiProxy } from './apiproxy.js';
|
||||
function unwrap(res) {
|
||||
const r = res;
|
||||
if (r?.result?.ok === true)
|
||||
return r.result.value;
|
||||
return undefined;
|
||||
}
|
||||
function unwrapError(res) {
|
||||
const r = res;
|
||||
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, payload) {
|
||||
if (typeof fn !== 'function')
|
||||
throw new Error('apiProxy sessions API unavailable');
|
||||
return fn({ rpcId: randomUUID(), payload });
|
||||
}
|
||||
export async function loadSessionModels(ctx, sessionId) {
|
||||
const api = ctx.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(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 = [];
|
||||
for (const group of value.groups ?? []) {
|
||||
for (const m of group.models ?? []) {
|
||||
@@ -34,16 +49,20 @@ export async function loadSessionModels(ctx, sessionId) {
|
||||
};
|
||||
}
|
||||
export async function selectSessionModel(ctx, sessionId, selection) {
|
||||
const api = ctx.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(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;
|
||||
}
|
||||
export function formatModel(sel) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-telegram-channel",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "DeepSeek Harness Telegram mobile remote: workspace→session picker (Web-aligned), /model switch, same trajectory — dsh-plugin",
|
||||
"keywords": [
|
||||
"telegram",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { resolveApiProxy } from '../src/apiproxy.ts'
|
||||
|
||||
test('resolveApiProxy uses ctx.get and avoids bare ctx.apiProxy', () => {
|
||||
const api = { sessions: {} }
|
||||
let touchedBare = false
|
||||
const ctx = {
|
||||
get(name: string) {
|
||||
return name === 'apiProxy' ? api : undefined
|
||||
},
|
||||
get apiProxy() {
|
||||
touchedBare = true
|
||||
throw new Error('cannot get property "apiProxy" without inject')
|
||||
},
|
||||
}
|
||||
assert.equal(resolveApiProxy(ctx as any), api)
|
||||
assert.equal(touchedBare, false)
|
||||
})
|
||||
|
||||
test('resolveApiProxy falls back to own-property mock', () => {
|
||||
const api = { sessions: {} }
|
||||
const ctx = { apiProxy: api }
|
||||
assert.equal(resolveApiProxy(ctx as any), api)
|
||||
})
|
||||
Reference in New Issue
Block a user