1
0

cli.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /**
  2. * Dependency-free CLI parsing for the standalone mock LLM server.
  3. * @module @deepseek-ai/dsh-llm-mock-server/cli
  4. */
  5. import { parseArgs } from 'node:util'
  6. import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
  7. import type {
  8. ConcreteMockLlmBehavior,
  9. MockLlmBehavior,
  10. MockLlmRandomWeights,
  11. MockLlmServerOptions,
  12. } from './index.ts'
  13. /** Listener lifecycle behavior understood only by the standalone CLI. */
  14. export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused'
  15. /** Parsed CLI configuration, including a pre-listen unavailable interval. */
  16. export interface MockLlmCliConfig {
  17. /** Server options after removing the lifecycle-only `connection_refused` entry. */
  18. readonly server: MockLlmServerOptions
  19. /** Delay before binding the model port; an integer from zero through the Node timer maximum. */
  20. readonly listenDelayMs: number
  21. /** Whether the original sequence requested a true pre-listen refusal phase. */
  22. readonly startsUnavailable: boolean
  23. }
  24. /** Result of parsing `dsh-llm-mock-server` arguments. */
  25. export type MockLlmCliParseResult =
  26. | { readonly kind: 'help' }
  27. | { readonly kind: 'run'; readonly config: MockLlmCliConfig }
  28. const BEHAVIORS = new Set<string>(MOCK_LLM_BEHAVIORS)
  29. const DEFAULT_LISTEN_DELAY_MS = 750
  30. /** Command usage written for `--help` and invalid arguments. */
  31. export const MOCK_LLM_CLI_USAGE = `Usage: dsh-llm-mock-server [options]
  32. Required:
  33. --sequence <a,b,...> Ordered behaviors; connection_refused is allowed first
  34. Listener:
  35. --host <host> Default 127.0.0.1
  36. --port <port> Default 8000; required and nonzero for connection_refused
  37. --api-key <token> Validate exact Bearer token when present
  38. --listen-delay-ms <ms> Unavailable interval (default 750 with connection_refused)
  39. --repeat-last Repeat the final request behavior after exhaustion
  40. --seed <uint32> Reproduce random selections
  41. --random-weights <a=n,...> Relative weights for concrete behaviors
  42. Response:
  43. --success-text <text>
  44. --partial-text <text>
  45. --reasoning-text <text>
  46. --chunk-size <count>
  47. --chunk-delay-ms <ms>
  48. --disconnect-delay-ms <ms>
  49. --retry-after-ms <ms>
  50. --request-id <id>
  51. --tool-name <name>
  52. --tool-arguments <json>
  53. Other:
  54. --help
  55. `
  56. function numberValue(option: string, value: string): number {
  57. const parsed = Number(value)
  58. if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
  59. return parsed
  60. }
  61. function boundedIntegerValue(option: string, value: string, min: number, max: number): number {
  62. const parsed = numberValue(option, value)
  63. if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
  64. throw new Error(`dsh-llm-mock-server: ${option} must be an integer between ${min} and ${max}`)
  65. }
  66. return parsed
  67. }
  68. function parseSequence(raw: string): { startsUnavailable: boolean; sequence: MockLlmBehavior[] } {
  69. const entries = raw.split(',').map(entry => entry.trim())
  70. if (entries.some(entry => entry.length === 0)) {
  71. throw new Error('dsh-llm-mock-server: --sequence must contain non-empty comma-separated behaviors')
  72. }
  73. const startsUnavailable = entries[0] === CONNECTION_REFUSED_BEHAVIOR
  74. if (entries.slice(1).includes(CONNECTION_REFUSED_BEHAVIOR)) {
  75. throw new Error('dsh-llm-mock-server: connection_refused is allowed only as the first behavior')
  76. }
  77. const requestEntries = startsUnavailable ? entries.slice(1) : entries
  78. if (requestEntries.length === 0) {
  79. throw new Error('dsh-llm-mock-server: connection_refused must be followed by a request behavior')
  80. }
  81. for (const entry of requestEntries) {
  82. if (!BEHAVIORS.has(entry)) throw new Error(`dsh-llm-mock-server: unknown behavior ${JSON.stringify(entry)}`)
  83. }
  84. return { startsUnavailable, sequence: requestEntries as MockLlmBehavior[] }
  85. }
  86. function parseRandomWeights(raw: string): MockLlmRandomWeights {
  87. const weights: MockLlmRandomWeights = {}
  88. for (const entry of raw.split(',')) {
  89. const [behavior, rawWeight, ...extra] = entry.split('=')
  90. if (behavior === undefined || behavior === '' || rawWeight === undefined || rawWeight === '' || extra.length > 0) {
  91. throw new Error('dsh-llm-mock-server: --random-weights expects behavior=weight comma-separated entries')
  92. }
  93. if (!BEHAVIORS.has(behavior) || behavior === 'random') {
  94. throw new Error(`dsh-llm-mock-server: random weight requires a concrete behavior, got ${JSON.stringify(behavior)}`)
  95. }
  96. if (Object.hasOwn(weights, behavior)) {
  97. throw new Error(`dsh-llm-mock-server: duplicate random weight for ${JSON.stringify(behavior)}`)
  98. }
  99. weights[behavior as ConcreteMockLlmBehavior] = numberValue('--random-weights', rawWeight)
  100. }
  101. return weights
  102. }
  103. /** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */
  104. const CLI_OPTIONS = {
  105. 'sequence': { type: 'string' },
  106. 'host': { type: 'string' },
  107. 'port': { type: 'string' },
  108. 'api-key': { type: 'string' },
  109. 'listen-delay-ms': { type: 'string' },
  110. 'repeat-last': { type: 'boolean' },
  111. 'seed': { type: 'string' },
  112. 'random-weights': { type: 'string' },
  113. 'success-text': { type: 'string' },
  114. 'partial-text': { type: 'string' },
  115. 'reasoning-text': { type: 'string' },
  116. 'chunk-size': { type: 'string' },
  117. 'chunk-delay-ms': { type: 'string' },
  118. 'disconnect-delay-ms': { type: 'string' },
  119. 'retry-after-ms': { type: 'string' },
  120. 'request-id': { type: 'string' },
  121. 'tool-name': { type: 'string' },
  122. 'tool-arguments': { type: 'string' },
  123. } as const
  124. /**
  125. * Parse standalone server arguments without starting a process or listener.
  126. * Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
  127. * coercion, bounds, and cross-option constraints remain manual below it.
  128. * @param argv - arguments after the executable name.
  129. * @returns help or validated run configuration.
  130. */
  131. export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
  132. if (argv.includes('--help')) return { kind: 'help' }
  133. const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false })
  134. const host = values.host
  135. const port = values.port === undefined ? 8_000 : numberValue('--port', values.port)
  136. const apiKey = values['api-key']
  137. const listenDelayMs = values['listen-delay-ms'] === undefined
  138. ? undefined
  139. : boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
  140. const repeatLast = values['repeat-last'] ?? false
  141. const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed)
  142. const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights'])
  143. const successText = values['success-text']
  144. const partialText = values['partial-text']
  145. const reasoningText = values['reasoning-text']
  146. const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size'])
  147. const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms'])
  148. const disconnectDelayMs = values['disconnect-delay-ms'] === undefined
  149. ? undefined
  150. : numberValue('--disconnect-delay-ms', values['disconnect-delay-ms'])
  151. const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms'])
  152. const requestId = values['request-id']
  153. const toolName = values['tool-name']
  154. const toolArguments = values['tool-arguments']
  155. if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
  156. const sequenceRaw = values.sequence
  157. const parsedSequence = parseSequence(sequenceRaw)
  158. if (parsedSequence.startsUnavailable && port === 0) {
  159. throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')
  160. }
  161. if (!parsedSequence.startsUnavailable && listenDelayMs !== undefined) {
  162. throw new Error('dsh-llm-mock-server: --listen-delay-ms requires connection_refused first in --sequence')
  163. }
  164. if (!parsedSequence.sequence.includes('random') && (randomSeed !== undefined || randomWeights !== undefined)) {
  165. throw new Error('dsh-llm-mock-server: --seed and --random-weights require random in --sequence')
  166. }
  167. return {
  168. kind: 'run',
  169. config: {
  170. server: {
  171. sequence: parsedSequence.sequence,
  172. port,
  173. repeatLast,
  174. ...randomSeed === undefined ? {} : { randomSeed },
  175. ...randomWeights === undefined ? {} : { randomWeights },
  176. ...host === undefined ? {} : { host },
  177. ...apiKey === undefined ? {} : { apiKey },
  178. ...successText === undefined ? {} : { successText },
  179. ...partialText === undefined ? {} : { partialText },
  180. ...reasoningText === undefined ? {} : { reasoningText },
  181. ...chunkSize === undefined ? {} : { chunkSize },
  182. ...chunkDelayMs === undefined ? {} : { chunkDelayMs },
  183. ...disconnectDelayMs === undefined ? {} : { disconnectDelayMs },
  184. ...retryAfterMs === undefined ? {} : { retryAfterMs },
  185. ...requestId === undefined ? {} : { requestId },
  186. ...toolName === undefined ? {} : { toolName },
  187. ...toolArguments === undefined ? {} : { toolArguments },
  188. },
  189. listenDelayMs: parsedSequence.startsUnavailable ? listenDelayMs ?? DEFAULT_LISTEN_DELAY_MS : 0,
  190. startsUnavailable: parsedSequence.startsUnavailable,
  191. },
  192. }
  193. }