api-proxy-default-route.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /**
  2. * The `api-gateway` settings section over a REAL settings provider: the
  3. * composition entry as the base layer, the wholesale replace the gateway
  4. * persists with, and the fallback when the provider detaches. The other model
  5. * specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so
  6. * this is the only place the layering itself is exercised.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import { Context } from 'cordis'
  10. import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings'
  11. import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
  12. import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts'
  13. import type { DefaultRouteSettings } from '../src/index.ts'
  14. /** The smallest real provider: one in-memory document, always writable. */
  15. class MemorySettings extends Settings {
  16. doc: Record<string, unknown> = {}
  17. get writable(): boolean {
  18. return true
  19. }
  20. protected load(): Promise<Record<string, unknown>> {
  21. return Promise.resolve(structuredClone(this.doc))
  22. }
  23. protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
  24. this.doc = { ...this.doc, [ns]: structuredClone(section) }
  25. return Promise.resolve()
  26. }
  27. }
  28. /** Mount the gateway's own section wiring over a live provider. */
  29. async function boot(entry: DefaultRouteSettings) {
  30. const ctx = new Context()
  31. const fiber = ctx.plugin(MemorySettings)
  32. await fiber.await()
  33. let route: () => DefaultRouteSettings = () => entry
  34. const consumer = ctx.plugin(function section(child: Context) {
  35. installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
  36. setSource: (current) => { route = current },
  37. onChange: () => {},
  38. })
  39. })
  40. await consumer.await()
  41. const settings = ctx.get('settings')
  42. if (settings === undefined) throw new Error('settings provider did not mount')
  43. return { ctx, fiber, consumer, settings, read: () => route() }
  44. }
  45. describe('the api-gateway default-route section', () => {
  46. it('resolves the composition entry until the user layer overrides it', async () => {
  47. const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  48. expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  49. await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
  50. provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
  51. })
  52. expect(bench.read()).toEqual({
  53. provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
  54. })
  55. await bench.ctx.fiber.dispose()
  56. })
  57. it('clears a stored effort when the next switch has none', async () => {
  58. const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  59. await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
  60. provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
  61. })
  62. expect(bench.read().reasoningEffort).toBe('high')
  63. // The whole reason the gateway persists with `replace` rather than a merge
  64. // patch — and the reason `Config` carries no effort for the base layer to
  65. // re-inherit here. A stranded effort would fail the next session's first
  66. // request against a model that does not support it.
  67. await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
  68. provider: 'acme-gateway', model: 'acme-plain',
  69. })
  70. expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' })
  71. await bench.ctx.fiber.dispose()
  72. })
  73. it('layers a hand-written partial section over the entry', async () => {
  74. const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  75. // Someone editing settings.yaml by hand may name only the model. The
  76. // entry supplies the provider, which is what makes this legal — and is
  77. // exactly why an effort in the entry could never be cleared, so there
  78. // is none to inherit.
  79. await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' })
  80. expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
  81. await bench.ctx.fiber.dispose()
  82. })
  83. it('falls back to the composition entry when the provider detaches', async () => {
  84. const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  85. await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
  86. provider: 'acme-gateway', model: 'acme-large',
  87. })
  88. expect(bench.read().provider).toBe('acme-gateway')
  89. // A deployment that loses its settings provider keeps serving the route it
  90. // was composed with rather than the one it can no longer read.
  91. await bench.fiber.dispose()
  92. expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  93. await bench.ctx.fiber.dispose()
  94. })
  95. })