api-proxy-models.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. /**
  2. * Web session model-directory and selection behavior: dynamic provider grouping,
  3. * provider-local catalog failures, logged-selection restoration without stale
  4. * catalog injection, advisory pass-through models, and the prompt-assembly
  5. * boundary for a running selection change.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import AttachmentStore from '@deepseek-ai/dsh-attachment'
  12. import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  13. import type {
  14. GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
  15. LlmResolvedModelInfo, StreamChunk,
  16. UserMessage,
  17. } from '@deepseek-ai/dsh-llm'
  18. import SessionStore from '@deepseek-ai/dsh-session'
  19. import type { SessionId } from '@deepseek-ai/dsh-session'
  20. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  21. import UserQuestionService from '@deepseek-ai/dsh-user-questions'
  22. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  23. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  24. import { createApiProxy } from '../src/api-proxy.ts'
  25. let nextRpc = 1
  26. function request<P>(payload: P): RpcRequest<P> {
  27. return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
  28. }
  29. class CatalogAdapter extends LlmAdapter {
  30. constructor(
  31. private readonly name: string,
  32. private readonly models: readonly LlmModelInfo[] | Error,
  33. private readonly reasoning?: LlmModelReasoningInfo,
  34. private readonly exactError?: Error,
  35. ) {
  36. super()
  37. }
  38. override providerInfo(provider: string): LlmProviderInfo {
  39. return { id: provider, name: this.name }
  40. }
  41. override listModels(): Promise<readonly LlmModelInfo[]> {
  42. return this.models instanceof Error
  43. ? Promise.reject(this.models)
  44. : Promise.resolve(this.models)
  45. }
  46. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  47. if (this.exactError !== undefined) return Promise.reject(this.exactError)
  48. return Promise.resolve({
  49. provider,
  50. id: model,
  51. name: model,
  52. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  53. })
  54. }
  55. override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  56. // Catalog tests never enter provider streaming.
  57. }
  58. }
  59. const REASONING: LlmModelReasoningInfo = {
  60. efforts: [
  61. { id: ReasoningEffortId('off'), name: 'Off' },
  62. { id: ReasoningEffortId('high'), name: 'High' },
  63. { id: ReasoningEffortId('max'), name: 'Max' },
  64. ],
  65. defaultEffort: ReasoningEffortId('high'),
  66. }
  67. async function harness(logged?: {
  68. provider: string
  69. model: string
  70. reasoningEffort?: ReasoningEffortId
  71. }): Promise<{
  72. ctx: Context
  73. agent: Agent
  74. sessionId: SessionId
  75. }> {
  76. const ctx = new Context()
  77. await ctx.plugin(SessionStore)
  78. await ctx.plugin(SystemPrompt, { persona: '' })
  79. await ctx.plugin(LlmRuntime)
  80. await ctx.plugin(UserQuestionService)
  81. await ctx.plugin(AgentRegistry)
  82. ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
  83. { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
  84. { provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
  85. ], REASONING))
  86. ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
  87. ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
  88. { provider: 'metadata-broken', id: 'listed', name: 'Listed' },
  89. ], undefined, new Error('reasoning metadata offline')))
  90. ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
  91. ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
  92. { provider: 'duplicate', id: 'same', name: 'Same' },
  93. { provider: 'duplicate', id: 'same', name: 'Same Again' },
  94. ]))
  95. const session = ctx.sessions.create()
  96. if (logged !== undefined) {
  97. session.append('request/header', { header: { config: logged }, reason: 'initial' })
  98. }
  99. const agent = {
  100. id: session.id,
  101. session,
  102. status: 'running',
  103. ctx,
  104. inbox: { nextTurn: [], nextStep: [] },
  105. } as unknown as Agent
  106. ctx.agents.register(agent)
  107. return { ctx, agent, sessionId: session.id }
  108. }
  109. function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
  110. if (!response.result.ok) throw new Error('expected successful response')
  111. return response.result.value
  112. }
  113. function registerTextOnly(ctx: Context): void {
  114. ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
  115. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  116. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
  117. }
  118. }('Text Only', []))
  119. }
  120. describe('Web session model selection', () => {
  121. it('validates an ordered image batch before persisting any member', async () => {
  122. const { ctx, agent, sessionId } = await harness()
  123. const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
  124. const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
  125. attachmentId: `att-${String(input.data[0])}`,
  126. mediaType: input.mediaType,
  127. bytes: input.data.byteLength,
  128. width: 1,
  129. height: 1,
  130. ...input.name === undefined ? {} : { name: input.name },
  131. }))
  132. const attachments = {
  133. imageLimits: {
  134. maxImageBytes: 4,
  135. maxImagesPerMessage: 2,
  136. maxMessageImageBytes: 4,
  137. maxImagePixels: 4,
  138. maxImageDimension: 2000,
  139. mediaTypes: ['image/png'],
  140. },
  141. validateImage,
  142. saveImage,
  143. }
  144. ctx.provide('attachments', {
  145. ...attachments,
  146. saveImages(inputs: readonly Parameters<typeof saveImage>[0][]) {
  147. return AttachmentStore.prototype.saveImages.call(attachments, inputs)
  148. },
  149. } as never)
  150. const followup = vi.fn()
  151. Object.assign(agent, { followup })
  152. const api = createApiProxy(ctx, {
  153. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  154. cwd: '/tmp',
  155. })
  156. const result = await api.sessions.prompt(request({
  157. sessionId,
  158. mode: 'queue' as const,
  159. content: [
  160. { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
  161. { type: 'text' as const, text: 'compare' },
  162. { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
  163. ],
  164. }))
  165. expect(result.result.ok).toBe(true)
  166. expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
  167. expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
  168. expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
  169. {
  170. type: 'image',
  171. attachment: {
  172. attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
  173. },
  174. },
  175. { type: 'text', text: 'compare' },
  176. { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  177. ])
  178. const denied = await api.sessions.prompt(request({
  179. sessionId,
  180. mode: 'queue' as const,
  181. content: Array.from({ length: 3 }, () => ({
  182. type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
  183. })),
  184. }))
  185. expect(denied.result).toMatchObject({
  186. ok: false,
  187. error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
  188. })
  189. expect(saveImage).toHaveBeenCalledTimes(2)
  190. await ctx.fiber.dispose()
  191. })
  192. it('refuses a text-only selection while durable or pending image content remains visible', async () => {
  193. const { ctx, agent, sessionId } = await harness()
  194. registerTextOnly(ctx)
  195. const api = createApiProxy(ctx, {
  196. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  197. cwd: '/tmp',
  198. })
  199. const image = {
  200. type: 'image' as const,
  201. attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
  202. }
  203. agent.session.append('user/message', {
  204. id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
  205. } as never, { surfaceOp: 'append' })
  206. expect((await api.sessions.selectModel(request({
  207. sessionId, provider: 'text-only', model: 'plain',
  208. }))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
  209. agent.session.append('user/message', {
  210. id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
  211. content: [{ type: 'text', text: 'image summarized' }],
  212. } as never, {
  213. surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
  214. sourceEventSeqs: agent.session.events.map(event => event.seq),
  215. })
  216. ;(agent.inbox.nextTurn as UserMessage[]).push({
  217. id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
  218. } as never)
  219. expect((await api.sessions.selectModel(request({
  220. sessionId, provider: 'text-only', model: 'plain',
  221. }))).result.ok).toBe(false)
  222. ;(agent.inbox.nextTurn as UserMessage[]).length = 0
  223. expect(expectValue(await api.sessions.selectModel(request({
  224. sessionId, provider: 'text-only', model: 'plain',
  225. }))).selected).toEqual({ provider: 'text-only', model: 'plain' })
  226. await ctx.fiber.dispose()
  227. })
  228. it('authorizes attachment bytes only when the session event stream references the id', async () => {
  229. const { ctx, agent, sessionId } = await harness()
  230. const ref = {
  231. attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
  232. }
  233. const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
  234. ctx.provide('attachments', { readImage } as never)
  235. const api = createApiProxy(ctx, {
  236. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  237. cwd: '/tmp',
  238. })
  239. agent.session.append('agent/inbox/spliced', {
  240. target: 'next-turn',
  241. start: 0,
  242. inserted: [{
  243. id: 'queued-image', role: 'user', source: { kind: 'user' },
  244. content: [{ type: 'image', attachment: ref }],
  245. }],
  246. } as never)
  247. const allowed = await api.sessions.attachment(request({
  248. sessionId, attachmentId: 'att-authorized' as never,
  249. }))
  250. expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
  251. const denied = await api.sessions.attachment(request({
  252. sessionId, attachmentId: 'att-other' as never,
  253. }))
  254. expect(denied.result).toMatchObject({
  255. ok: false,
  256. error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
  257. })
  258. expect(readImage).toHaveBeenCalledOnce()
  259. await ctx.fiber.dispose()
  260. })
  261. it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
  262. const { ctx, sessionId } = await harness({
  263. provider: 'deepseek-official',
  264. model: 'private-preview',
  265. reasoningEffort: ReasoningEffortId('max'),
  266. })
  267. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
  268. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  269. expect(catalog.current).toEqual({
  270. provider: 'deepseek-official',
  271. model: 'private-preview',
  272. reasoningEffort: 'max',
  273. })
  274. expect(catalog.groups).toEqual([{
  275. id: 'deepseek-official',
  276. name: 'DeepSeek',
  277. models: [
  278. { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
  279. {
  280. id: 'deepseek-reasoner',
  281. name: 'DeepSeek Reasoner',
  282. description: 'Reasoning model',
  283. reasoning: REASONING,
  284. },
  285. ],
  286. }])
  287. expect(catalog.failures).toEqual([
  288. { id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
  289. { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
  290. {
  291. id: 'duplicate',
  292. name: 'Duplicate Provider',
  293. message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
  294. },
  295. ])
  296. await ctx.fiber.dispose()
  297. })
  298. it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
  299. const { ctx, agent, sessionId } = await harness()
  300. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
  301. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  302. const signal = new AbortController().signal
  303. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  304. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  305. expect((await ctx.systemPrompt.assemble()).variables)
  306. .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
  307. const selected = expectValue(await api.sessions.selectModel(request({
  308. sessionId,
  309. provider: 'deepseek-official',
  310. model: 'private-preview',
  311. reasoningEffort: 'max',
  312. })))
  313. expect(selected.selected).toEqual({
  314. provider: 'deepseek-official',
  315. model: 'private-preview',
  316. reasoningEffort: 'max',
  317. })
  318. await expect(agentEvents(ctx, agent).waterfall(
  319. 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
  320. )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
  321. expect((await ctx.systemPrompt.assemble()).variables)
  322. .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
  323. await expect(agentEvents(ctx, agent).waterfall(
  324. 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
  325. )).resolves.toMatchObject({
  326. provider: 'deepseek-official',
  327. model: 'private-preview',
  328. reasoningEffort: 'max',
  329. })
  330. const unsupported = await api.sessions.selectModel(request({
  331. sessionId,
  332. provider: 'deepseek-official',
  333. model: 'private-preview',
  334. reasoningEffort: 'medium',
  335. }))
  336. expect(unsupported.result).toMatchObject({
  337. ok: false,
  338. error: {
  339. code: 'model-unavailable',
  340. message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
  341. },
  342. })
  343. const rejected = await api.sessions.selectModel(request({
  344. sessionId,
  345. provider: 'missing',
  346. model: 'model',
  347. }))
  348. expect(rejected.result).toEqual({
  349. ok: false,
  350. error: {
  351. code: 'model-unavailable',
  352. message: 'no adapter registered for provider "missing"',
  353. details: { provider: 'missing', model: 'model' },
  354. },
  355. })
  356. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  357. .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
  358. await ctx.fiber.dispose()
  359. })
  360. it('reads the Agent default live for a session whose log names no selection', async () => {
  361. const { ctx, sessionId } = await harness()
  362. let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
  363. const api = createApiProxy(ctx, {
  364. defaultModelSelection: () => stored,
  365. cwd: '/tmp',
  366. })
  367. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  368. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  369. // The default moving after the session exists still reaches it: New
  370. // Session reuses a blank session rather than minting another, so a seed
  371. // captured at creation would show the superseded model there.
  372. stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
  373. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  374. .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
  375. expect(expectValue(await api.host.describe(request({}))))
  376. .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
  377. await ctx.fiber.dispose()
  378. })
  379. it('keeps a session on its logged selection when the Agent default differs', async () => {
  380. const { ctx, sessionId } = await harness({
  381. provider: 'deepseek-official',
  382. model: 'deepseek-chat',
  383. })
  384. let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
  385. const api = createApiProxy(ctx, {
  386. defaultModelSelection: () => stored,
  387. cwd: '/tmp',
  388. })
  389. stored = { provider: 'duplicate', model: 'same' }
  390. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  391. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  392. await ctx.fiber.dispose()
  393. })
  394. it('saves an accepted selection as the default and survives a storage failure', async () => {
  395. const { ctx, sessionId } = await harness()
  396. const saved: unknown[] = []
  397. let reject = false
  398. const api = createApiProxy(ctx, {
  399. defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  400. saveDefaultModelSelection: (selection) => {
  401. saved.push(selection)
  402. return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
  403. },
  404. cwd: '/tmp',
  405. })
  406. expectValue(await api.sessions.selectModel(request({
  407. sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
  408. })))
  409. expect(saved).toEqual([
  410. { provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
  411. ])
  412. // A refused selection never becomes anyone's default.
  413. await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
  414. expect(saved).toHaveLength(1)
  415. // Storage failing is not the selection failing: the switch already applies
  416. // to this session, so the call still succeeds.
  417. reject = true
  418. const stillAccepted = expectValue(await api.sessions.selectModel(request({
  419. sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
  420. })))
  421. expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
  422. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  423. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
  424. await ctx.fiber.dispose()
  425. })
  426. it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
  427. const { ctx, sessionId } = await harness()
  428. const api = createApiProxy(ctx, {
  429. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
  430. cwd: '/tmp',
  431. })
  432. // The client disabling its input is an affordance; this method stays
  433. // callable, so the refusal has to live here.
  434. const refused = await api.sessions.prompt(request({
  435. sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
  436. }))
  437. expect(refused.result).toMatchObject({
  438. ok: false,
  439. error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
  440. })
  441. expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
  442. // An advisory-unlisted model on a live route is NOT this: the route
  443. // serves it, so the prompt goes through and nothing blocks.
  444. expectValue(await api.sessions.selectModel(request({
  445. sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
  446. })))
  447. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  448. expect(catalog.routable).toBe(true)
  449. expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
  450. .not.toContain('unlisted-but-served')
  451. await ctx.fiber.dispose()
  452. })
  453. it('serves a session and its catalog when the stored default names a route that is gone', async () => {
  454. const { ctx, sessionId } = await harness()
  455. const api = createApiProxy(ctx, {
  456. // What a Models-page removal leaves behind: the settings document still
  457. // names the route the user last picked, and nothing serves it.
  458. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
  459. cwd: '/tmp',
  460. })
  461. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  462. // Passed through rather than repaired: matching no group is precisely what
  463. // makes the composer seat prompt for a selection instead of naming a model
  464. // the deployment cannot reach.
  465. expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
  466. expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
  467. .not.toContain('deleted-gateway/deleted-model')
  468. await ctx.fiber.dispose()
  469. })
  470. })