codexRuntime.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import { describe, expect, it } from 'vitest';
  2. import { buildArgs, DEFAULT_API_KEY_ENV, toToolSummaries, type CodexProviderSpec } from './codexRuntime';
  3. /** 这组断言锁的是「喂给 codex.exe 的 -c 参数」——provider 能不能生效全在这里 */
  4. const API_KEY = 'sk-unit-secret-abcdef123456';
  5. const custom: CodexProviderSpec = {
  6. id: 'zsjz',
  7. name: '知数 · 第三方',
  8. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  9. model: 'qwen3-max',
  10. apiKey: API_KEY,
  11. httpHeaders: { 'X-DashScope-WorkSpace': 'ws-1' },
  12. extra: { request_max_retries: 3, temperature: 0.5, query_params: { version: 'v2' } },
  13. };
  14. describe('buildArgs', () => {
  15. it('没有 provider 时只起 app-server,且不传 --strict-config', () => {
  16. expect(buildArgs(null)).toEqual(['app-server', '--listen', 'stdio://']);
  17. expect(buildArgs(null)).not.toContain('--strict-config');
  18. });
  19. it('自定义 provider 走 responses + 免登录 + env_key', () => {
  20. const args = buildArgs(custom);
  21. expect(args).toContain('-c');
  22. expect(args.join(' ')).toContain('model_provider="zsjz"');
  23. expect(args.join(' ')).toContain('model="qwen3-max"');
  24. expect(args.join(' ')).toContain('model_providers.zsjz.wire_api="responses"');
  25. expect(args.join(' ')).toContain('model_providers.zsjz.requires_openai_auth=false');
  26. expect(args.join(' ')).toContain(`model_providers.zsjz.env_key="${DEFAULT_API_KEY_ENV}"`);
  27. expect(args.join(' ')).toContain('model_providers.zsjz.base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"');
  28. });
  29. it('API Key 绝不出现在命令行参数里', () => {
  30. expect(JSON.stringify(buildArgs(custom))).not.toContain(API_KEY);
  31. });
  32. it('没有 apiKey 时不写 env_key(本地无鉴权服务)', () => {
  33. const args = buildArgs({ ...custom, apiKey: null });
  34. expect(args.join(' ')).not.toContain('env_key');
  35. });
  36. it('http_headers 与 query_params 输出成 TOML inline table', () => {
  37. const joined = buildArgs(custom).join(' ');
  38. // 只含 A-Za-z0-9_- 的键按 TOML bare key 规则输出,无需加引号
  39. expect(joined).toContain('model_providers.zsjz.http_headers={X-DashScope-WorkSpace="ws-1"}');
  40. expect(joined).toContain('model_providers.zsjz.query_params={version="v2"}');
  41. });
  42. it('config 白名单外的键被丢弃', () => {
  43. const joined = buildArgs(custom).join(' ');
  44. expect(joined).toContain('request_max_retries=3');
  45. expect(joined).not.toContain('temperature');
  46. });
  47. it('provider 一律用自定义 id,绝不落在 Codex 保留的内置 id 上', () => {
  48. const joined = buildArgs(custom).join(' ');
  49. expect(joined).toContain('model_provider="zsjz"');
  50. // 覆盖 model_providers.ollama 之类会让 app-server 启动即退出
  51. expect(joined).not.toMatch(/model_providers\.(ollama|lmstudio|openai)\b/u);
  52. });
  53. it('重试次数 / 空闲超时 / websocket 开关都会写进 -c', () => {
  54. const joined = buildArgs({
  55. ...custom,
  56. extra: {
  57. request_max_retries: 0,
  58. stream_max_retries: 0,
  59. stream_idle_timeout_ms: 300_000,
  60. supports_websockets: false,
  61. },
  62. }).join(' ');
  63. expect(joined).toContain('model_providers.zsjz.stream_max_retries=0');
  64. expect(joined).toContain('model_providers.zsjz.request_max_retries=0');
  65. expect(joined).toContain('model_providers.zsjz.stream_idle_timeout_ms=300000');
  66. expect(joined).toContain('model_providers.zsjz.supports_websockets=false');
  67. });
  68. it('extra 里没有的键绝不写进 -c(supports_websockets 是 serde 默认值,属于空操作)', () => {
  69. const joined = buildArgs({ ...custom, extra: { request_max_retries: 0 } }).join(' ');
  70. expect(joined).not.toContain('supports_websockets');
  71. // websocket_connect_timeout_ms 是真实键(15 秒那个),允许显式覆盖
  72. const withWsTimeout = buildArgs({ ...custom, extra: { websocket_connect_timeout_ms: 5_000 } }).join(' ');
  73. expect(withWsTimeout).toContain('model_providers.zsjz.websocket_connect_timeout_ms=5000');
  74. });
  75. it('modelCatalogPath 会以 model_catalog_json 传入,未设置时不出现该键', () => {
  76. const withCatalog = buildArgs({ ...custom, modelCatalogPath: 'C:\\data\\codex-data\\model-catalog.json' }).join(' ');
  77. expect(withCatalog).toContain('model_catalog_json="C:\\\\data\\\\codex-data\\\\model-catalog.json"');
  78. expect(buildArgs(custom).join(' ')).not.toContain('model_catalog_json');
  79. });
  80. it('值里的引号与反斜杠被 TOML 安全转义', () => {
  81. const joined = buildArgs({
  82. ...custom,
  83. name: '带"引号"和\\反斜杠',
  84. baseUrl: 'https://x/v1',
  85. }).join(' ');
  86. expect(joined).toContain('model_providers.zsjz.name="带\\"引号\\"和\\\\反斜杠"');
  87. });
  88. it('嵌套 map 输出成 TOML inline table,而不是 JSON', () => {
  89. const joined = buildArgs({
  90. ...custom,
  91. extra: { query_params: { version: 'v2', flag: 'on' } },
  92. }).join(' ');
  93. expect(joined).toContain('model_providers.zsjz.query_params={version="v2",flag="on"}');
  94. expect(joined).not.toContain('{"version"');
  95. });
  96. it('不符合 bare key 规则的键加引号', () => {
  97. const joined = buildArgs({ ...custom, httpHeaders: { 'X Token': 'a' } }).join(' ');
  98. expect(joined).toContain('model_providers.zsjz.http_headers={"X Token"="a"}');
  99. });
  100. });
  101. /** mcpServerStatus/list 的 tools 详情结构不由我们决定,这里锁住退化行为 */
  102. describe('toToolSummaries', () => {
  103. it('详情里带 name / description 时优先取详情', () => {
  104. expect(toToolSummaries({ query_case: { name: 'query_case_v2', description: '按条件查案件' } })).toEqual([
  105. { name: 'query_case_v2', description: '按条件查案件' },
  106. ]);
  107. });
  108. it('详情形态不认识时退化成用 key 当工具名,描述留空', () => {
  109. expect(toToolSummaries({ list_tables: { inputSchema: {} }, ping: null })).toEqual([
  110. { name: 'list_tables', description: '' },
  111. { name: 'ping', description: '' },
  112. ]);
  113. });
  114. it('tools 缺失时返回空列表', () => {
  115. expect(toToolSummaries(undefined)).toEqual([]);
  116. expect(toToolSummaries(null)).toEqual([]);
  117. });
  118. });