model-selection.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /** Child LLM route selection for the subagent tool. */
  2. import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  3. import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
  4. import type { AgentOptions } from '@deepseek-ai/dsh-agent'
  5. import z from '@deepseek-ai/schemastery'
  6. /** One exact child LLM route authorized by a user setting. */
  7. export interface AllowedModelRoute {
  8. /** Registered LLM provider id. */
  9. readonly provider: string
  10. /** Provider-owned exact model id. */
  11. readonly model: string
  12. }
  13. /** Schema shared by the Host setting and its deployment base. */
  14. export const AllowedModelRouteSchema: z<AllowedModelRoute> = z.object({
  15. provider: z.string().min(1).required(),
  16. model: z.string().min(1).required(),
  17. })
  18. /** Route-selection authority captured by one delegation definition. */
  19. export interface ModelSelectionPolicy {
  20. /** Exact provider/model routes authorized for explicit selection. */
  21. readonly routes: readonly AllowedModelRoute[]
  22. }
  23. /**
  24. * Stable identity for one provider/model pair.
  25. * @param route - Exact provider/model route.
  26. * @returns Opaque key for equality checks.
  27. */
  28. export function modelRouteKey(route: AllowedModelRoute): string {
  29. return `${route.provider}\0${route.model}`
  30. }
  31. /**
  32. * Reject malformed or duplicate route policy entries at a durable or configuration boundary.
  33. * @param routes - Candidate exact routes to validate.
  34. * @returns an assertion that the candidate is a validated exact-route array.
  35. */
  36. export function assertAllowedModelRoutes(routes: unknown): asserts routes is readonly AllowedModelRoute[] {
  37. if (!Array.isArray(routes)) {
  38. throw new Error('subagent model selection requires an array of routes')
  39. }
  40. const seen = new Set<string>()
  41. const candidates: readonly unknown[] = routes
  42. for (const candidate of candidates) {
  43. if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)
  44. || !('provider' in candidate) || typeof candidate.provider !== 'string'
  45. || !('model' in candidate) || typeof candidate.model !== 'string'
  46. || candidate.provider.length === 0 || candidate.model.length === 0) {
  47. throw new Error('subagent model selection requires non-empty provider and model ids')
  48. }
  49. const route = { provider: candidate.provider, model: candidate.model }
  50. const key = modelRouteKey(route)
  51. if (seen.has(key)) {
  52. throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`)
  53. }
  54. seen.add(key)
  55. }
  56. }
  57. /** Model-facing child LLM route fields. */
  58. export interface DelegationModelRequest {
  59. readonly provider?: string
  60. readonly model?: string
  61. readonly reasoning_effort?: string
  62. }
  63. /**
  64. * Whether a call explicitly selects any child LLM value.
  65. * @param request - Model-facing route fields from the tool call.
  66. * @returns Whether at least one route or effort field is present.
  67. */
  68. export function hasDelegationModelRequest(request: DelegationModelRequest): boolean {
  69. return request.provider !== undefined
  70. || request.model !== undefined
  71. || request.reasoning_effort !== undefined
  72. }
  73. /** Reject an empty model-facing route value at the tool JSON boundary. */
  74. function assertNonEmpty(value: string | undefined, field: keyof DelegationModelRequest): void {
  75. if (value !== undefined && value.length === 0) {
  76. throw new Error(`child LLM \`${field}\` must be non-empty`)
  77. }
  78. }
  79. /**
  80. * Merge model-supplied selection fields over configured child defaults.
  81. * Provider and model form one route and must be supplied together. Changing
  82. * that route without an effort clears the configured route-owned effort.
  83. * @param parentOptions - Current parent values that supply missing child values.
  84. * @param configured - Tool-instance child defaults.
  85. * @param request - Model-facing route override.
  86. * @param enabled - Whether this tool instance permits model-facing selection.
  87. * @returns Child Agent options, preserving omission when no layer contributes one.
  88. */
  89. export function requestedAgentOptions(
  90. parentOptions: AgentOptions,
  91. configured: AgentOptions | undefined,
  92. request: DelegationModelRequest,
  93. enabled: boolean,
  94. ): AgentOptions | undefined {
  95. if (!hasDelegationModelRequest(request)) return configured
  96. if (!enabled) {
  97. throw new Error('child model selection is disabled for this tool instance')
  98. }
  99. assertNonEmpty(request.provider, 'provider')
  100. assertNonEmpty(request.model, 'model')
  101. assertNonEmpty(request.reasoning_effort, 'reasoning_effort')
  102. if ((request.provider === undefined) !== (request.model === undefined)) {
  103. throw new Error('child LLM `provider` and `model` must be supplied together')
  104. }
  105. const baselineProvider = configured?.provider ?? parentOptions.provider
  106. const baselineModel = configured?.model ?? parentOptions.model
  107. const routeChanged = request.provider !== undefined
  108. && (request.provider !== baselineProvider || request.model !== baselineModel)
  109. const { reasoningEffort: _configuredReasoningEffort, ...configuredWithoutReasoning } = configured ?? {}
  110. return {
  111. ...routeChanged && request.reasoning_effort === undefined ? configuredWithoutReasoning : configured,
  112. ...request.provider === undefined ? {} : { provider: request.provider, model: request.model },
  113. ...request.reasoning_effort === undefined
  114. ? {}
  115. : { reasoningEffort: ReasoningEffortId(request.reasoning_effort) },
  116. }
  117. }
  118. /**
  119. * Enforce a settings-owned route list at the operation that creates the child.
  120. * Pure inheritance remains outside this policy because no model-facing choice
  121. * occurred; any explicit route or effort field must resolve to an allowed route.
  122. * @param policy - Selection authority captured for this Session.
  123. * @param parentOptions - Current parent values that supply missing child values.
  124. * @param requested - Effective child options after request/config merging.
  125. * @param request - Model-facing selection fields from the tool call.
  126. */
  127. export function assertAllowedModelSelection(
  128. policy: ModelSelectionPolicy | undefined,
  129. parentOptions: AgentOptions,
  130. requested: AgentOptions | undefined,
  131. request: DelegationModelRequest,
  132. ): void {
  133. if (policy === undefined || !hasDelegationModelRequest(request)) return
  134. const provider = requested?.provider ?? parentOptions.provider
  135. const model = requested?.model ?? parentOptions.model
  136. if (provider === undefined || model === undefined) {
  137. throw new Error('cannot select child LLM values without an effective provider and model')
  138. }
  139. if (policy.routes.some(route => route.provider === provider && route.model === model)) return
  140. throw new Error(`child LLM route "${provider}/${model}" is not allowed for this Session`)
  141. }
  142. /**
  143. * Whether configured Agent options require route validation before delegation.
  144. * @param options - Tool-instance child defaults.
  145. * @returns Whether configured provider, model, or effort values must be resolved.
  146. */
  147. export function hasConfiguredLlmSelection(options: AgentOptions | undefined): boolean {
  148. return options?.provider !== undefined
  149. || options?.model !== undefined
  150. || options?.reasoningEffort !== undefined
  151. }
  152. /**
  153. * Resolve an effective child route through its live adapter before the child is
  154. * created. The LLM runtime owns provider lookup, exact-model metadata, effort
  155. * validation, and adapter defaults.
  156. * @param llm - Live LLM runtime.
  157. * @param parentOptions - Current parent values whose compatible fields the child inherits.
  158. * @param requested - Per-child options after request/config merging.
  159. * @param signal - Tool-call cancellation signal.
  160. * @param inheritParentReasoningEffort - Whether an omitted effort may inherit from the parent route.
  161. */
  162. export async function preflightChildLlmRoute(
  163. llm: LlmRuntime,
  164. parentOptions: AgentOptions,
  165. requested: AgentOptions | undefined,
  166. signal: AbortSignal,
  167. inheritParentReasoningEffort = true,
  168. ): Promise<void> {
  169. const provider = requested?.provider ?? parentOptions.provider
  170. const model = requested?.model ?? parentOptions.model
  171. if (provider === undefined || model === undefined) {
  172. throw new Error('cannot select child LLM values without an effective provider and model')
  173. }
  174. const routeChanged = provider !== parentOptions.provider || model !== parentOptions.model
  175. const reasoningEffort = requested?.reasoningEffort
  176. ?? (inheritParentReasoningEffort && !routeChanged ? parentOptions.reasoningEffort : undefined)
  177. await llm.resolveCallConfig({
  178. provider,
  179. model,
  180. ...reasoningEffort === undefined ? {} : { reasoningEffort },
  181. }, signal)
  182. }