@@ -10,6 +10,7 @@ import {
1010 type GenerateOptions ,
1111 type LlmCallConfig ,
1212 type LlmFailure ,
13+ type LlmResolvedModelInfo ,
1314 type PreparedLlmCall ,
1415 type StreamChunk ,
1516} from "@deepseek-ai/dsh-llm" ;
@@ -29,6 +30,7 @@ const HOST_LLM_TIMEOUT_CODE = "MEMOS_DSH_HOST_LLM_TIMEOUT";
2930const HOST_LLM_MESSAGE_SOURCE = "memos-local-memory" ;
3031const NO_REASONING_EFFORT = ReasoningEffortId ( "off" ) ;
3132const UNSUPPORTED_REASONING_EFFORT = "UNSUPPORTED_REASONING_EFFORT" ;
33+ const MODEL_CAPABILITY_TTL_MS = 10 * 60 * 1_000 ;
3234
3335/** Atomic provider/model route captured from the DSH agent that owns a turn. */
3436export interface DeepSeekHarnessLlmRoute {
@@ -40,12 +42,34 @@ export interface DeepSeekHarnessLlmRoute {
4042
4143/** Public subset of DSH's LLM runtime used by this adapter. */
4244export interface DeepSeekHarnessLlmLike {
45+ resolveModelInfo (
46+ provider : string ,
47+ model : string ,
48+ signal ?: AbortSignal ,
49+ ) : Promise < LlmResolvedModelInfo > ;
4350 prepareCall (
4451 config : LlmCallConfig ,
4552 signal ?: AbortSignal ,
4653 ) : Promise < PreparedLlmCall > ;
4754}
4855
56+ export interface DeepSeekHarnessHostLlmBridge extends HostLlmBridge {
57+ /** Drop exact-route capability snapshots after a DSH adapter topology update. */
58+ invalidateModelCapabilities ( ) : void ;
59+ }
60+
61+ type AuxiliaryReasoningCapability = "off" | "plain" ;
62+
63+ interface CapabilityCacheEntry {
64+ readonly expiresAt : number ;
65+ readonly value : Promise < AuxiliaryReasoningCapability > ;
66+ }
67+
68+ interface CapabilityCache {
69+ readonly entries : Map < string , CapabilityCacheEntry > ;
70+ generation : number ;
71+ }
72+
4973/**
5074 * Async route scope for MemOS work spawned by one DSH session.
5175 *
@@ -95,9 +119,17 @@ export interface CreateDeepSeekHarnessHostLlmBridgeOptions {
95119 */
96120export function createDeepSeekHarnessHostLlmBridge (
97121 options : CreateDeepSeekHarnessHostLlmBridgeOptions ,
98- ) : HostLlmBridge {
122+ ) : DeepSeekHarnessHostLlmBridge {
123+ const capabilityCache : CapabilityCache = {
124+ entries : new Map ( ) ,
125+ generation : 0 ,
126+ } ;
99127 return {
100128 id : HOST_LLM_BRIDGE_ID ,
129+ invalidateModelCapabilities ( ) : void {
130+ capabilityCache . generation ++ ;
131+ capabilityCache . entries . clear ( ) ;
132+ } ,
101133 async complete ( input : HostLlmCompleteInput ) : Promise < HostLlmCompletion > {
102134 const route = options . routes . current ( ) ;
103135 if ( ! route ) {
@@ -122,6 +154,7 @@ export function createDeepSeekHarnessHostLlmBridge(
122154 input ,
123155 route ,
124156 callDeadline . signal ,
157+ capabilityCache ,
125158 ) ;
126159 const request = createGenerateOptions ( input , route , prepared . config ) ;
127160 request . signal = callDeadline . signal ;
@@ -217,20 +250,30 @@ function createGenerateOptions(
217250 *
218251 * Retrieval filters and JSON extractors intentionally use small output caps.
219252 * Reusing a conversation's high reasoning effort can spend that entire cap on
220- * reasoning and produce no JSON/text. DSH effort ids are adapter-owned, so the
221- * exact registered adapter validates the branded conventional `off` id. Only
222- * an explicit unsupported-effort result retries without it, preserving the
223- * adapter/provider default. prepareCall performs no provider generation I/O
224- * and binds that validation to the same registration used for dispatch, even
225- * if HMR replaces the route before the returned stream starts.
253+ * reasoning and produce no JSON/text. Resolve exact-model metadata through
254+ * DSH, then cache whether its adapter advertises the conventional `off` id.
255+ * prepareCall remains the final registration-bound authority: if HMR changes
256+ * the route after metadata resolution, an explicit unsupported-effort result
257+ * refreshes the cache and retries with the adapter/provider default.
226258 */
227259async function prepareAuxiliaryCall (
228260 llm : DeepSeekHarnessLlmLike ,
229261 input : HostLlmCompleteInput ,
230262 route : DeepSeekHarnessLlmRoute ,
231263 signal : AbortSignal ,
264+ capabilityCache : CapabilityCache ,
232265) : Promise < PreparedLlmCall > {
233266 const config = createCallConfig ( input , route ) ;
267+ const capability = await resolveAuxiliaryReasoningCapability (
268+ llm ,
269+ route ,
270+ signal ,
271+ capabilityCache ,
272+ ) ;
273+ if ( capability === "plain" ) {
274+ return llm . prepareCall ( config , signal ) ;
275+ }
276+ const preparationGeneration = capabilityCache . generation ;
234277 try {
235278 return await llm . prepareCall (
236279 { ...config , reasoningEffort : NO_REASONING_EFFORT } ,
@@ -243,10 +286,64 @@ async function prepareAuxiliaryCall(
243286 ) {
244287 throw error ;
245288 }
289+ // The exact adapter may have changed after the metadata lookup. Preserve
290+ // prepareCall's registration-bound validation as the final authority and
291+ // remember the corrected capability only if no newer topology update has
292+ // already invalidated this preparation generation.
293+ if ( capabilityCache . generation === preparationGeneration ) {
294+ rememberAuxiliaryReasoningCapability ( route , "plain" , capabilityCache ) ;
295+ }
246296 return llm . prepareCall ( config , signal ) ;
247297 }
248298}
249299
300+ function capabilityRouteKey ( route : DeepSeekHarnessLlmRoute ) : string {
301+ return JSON . stringify ( [ route . provider , route . model ] ) ;
302+ }
303+
304+ function rememberAuxiliaryReasoningCapability (
305+ route : DeepSeekHarnessLlmRoute ,
306+ capability : AuxiliaryReasoningCapability ,
307+ cache : CapabilityCache ,
308+ ) : void {
309+ cache . entries . set ( capabilityRouteKey ( route ) , {
310+ expiresAt : Date . now ( ) + MODEL_CAPABILITY_TTL_MS ,
311+ value : Promise . resolve ( capability ) ,
312+ } ) ;
313+ }
314+
315+ async function resolveAuxiliaryReasoningCapability (
316+ llm : DeepSeekHarnessLlmLike ,
317+ route : DeepSeekHarnessLlmRoute ,
318+ signal : AbortSignal ,
319+ cache : CapabilityCache ,
320+ ) : Promise < AuxiliaryReasoningCapability > {
321+ const key = capabilityRouteKey ( route ) ;
322+ const cached = cache . entries . get ( key ) ;
323+ if ( cached && cached . expiresAt > Date . now ( ) ) {
324+ return cached . value ;
325+ }
326+ if ( cached ) cache . entries . delete ( key ) ;
327+
328+ let entry : CapabilityCacheEntry ;
329+ const value = llm . resolveModelInfo ( route . provider , route . model , signal )
330+ . then ( ( info ) : AuxiliaryReasoningCapability => (
331+ info . reasoning ?. efforts . some ( ( effort ) => effort . id === NO_REASONING_EFFORT )
332+ ? "off"
333+ : "plain"
334+ ) )
335+ . catch ( ( error : unknown ) => {
336+ if ( cache . entries . get ( key ) === entry ) cache . entries . delete ( key ) ;
337+ throw error ;
338+ } ) ;
339+ entry = {
340+ expiresAt : Date . now ( ) + MODEL_CAPABILITY_TTL_MS ,
341+ value,
342+ } ;
343+ cache . entries . set ( key , entry ) ;
344+ return value ;
345+ }
346+
250347function createCallConfig (
251348 input : HostLlmCompleteInput ,
252349 route : DeepSeekHarnessLlmRoute ,
0 commit comments