offload.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /**
  2. * Reasoning offload — config resolution, persistence, and strict degradation.
  3. *
  4. * The offload sends explore's assembled source to a BYO OpenAI-compatible
  5. * reasoning endpoint and returns the synthesized answer. Two invariants are
  6. * load-bearing and covered here:
  7. * 1. The API key is NEVER written to disk — the config stores only the NAME of
  8. * an env var (`keyEnv`); the key is resolved at call time.
  9. * 2. The path is STRICTLY DEGRADABLE — any failure (no endpoint, network error,
  10. * non-2xx, empty body) returns null so the caller serves local source; it
  11. * never throws and never surfaces an error to the agent.
  12. */
  13. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  14. import * as fs from 'fs';
  15. import * as path from 'path';
  16. import * as os from 'os';
  17. import {
  18. readOffloadConfig,
  19. writeOffloadConfig,
  20. resolveOffload,
  21. } from '../src/reasoning/config';
  22. import { isOffloadEnabled, synthesizeOffload, stripAgentDirectives } from '../src/reasoning/reasoner';
  23. describe('reasoning offload', () => {
  24. let home: string;
  25. // Point ~/.codegraph at a throwaway dir (os.homedir() honors $HOME on POSIX,
  26. // $USERPROFILE on Windows) + start from a clean env each test.
  27. const HOME_ENV = ['HOME', 'USERPROFILE'];
  28. const OFFLOAD_ENV = [
  29. 'CODEGRAPH_OFFLOAD_URL', 'CODEGRAPH_OFFLOAD_MODEL', 'CODEGRAPH_OFFLOAD_KEY',
  30. 'CODEGRAPH_OFFLOAD_EFFORT', 'CODEGRAPH_OFFLOAD_STYLE', 'CODEGRAPH_OFFLOAD_TIMEOUT_MS',
  31. 'CODEGRAPH_OFFLOAD_MAXTOKENS', 'CODEGRAPH_OFFLOAD_STRIP', 'CODEGRAPH_OFFLOAD_DEBUG',
  32. 'CEREBRAS_API_KEY',
  33. ];
  34. let saved: Record<string, string | undefined>;
  35. beforeEach(() => {
  36. home = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-offload-'));
  37. saved = {};
  38. for (const k of [...HOME_ENV, ...OFFLOAD_ENV]) { saved[k] = process.env[k]; delete process.env[k]; }
  39. process.env.HOME = home;
  40. process.env.USERPROFILE = home;
  41. });
  42. afterEach(() => {
  43. for (const k of [...HOME_ENV, ...OFFLOAD_ENV]) {
  44. if (saved[k] === undefined) delete process.env[k];
  45. else process.env[k] = saved[k];
  46. }
  47. vi.restoreAllMocks();
  48. if (fs.existsSync(home)) fs.rmSync(home, { recursive: true, force: true });
  49. });
  50. describe('config persistence', () => {
  51. it('is off, with sensible defaults, when nothing is configured', () => {
  52. const c = resolveOffload();
  53. expect(c.enabled).toBe(false);
  54. expect(c.origin).toBe('none');
  55. expect(c.model).toBe('gpt-oss-120b');
  56. expect(c.effort).toBe('low');
  57. expect(c.style).toBe('plain');
  58. expect(isOffloadEnabled()).toBe(false);
  59. });
  60. it('round-trips the config block and never writes the API key to disk', () => {
  61. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', model: 'gpt-oss-120b', keyEnv: 'CEREBRAS_API_KEY' });
  62. expect(readOffloadConfig().url).toBe('https://api.cerebras.ai/v1');
  63. const raw = fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8');
  64. expect(raw).toContain('CEREBRAS_API_KEY'); // the env var NAME is stored
  65. // ...but no actual secret material. Set a key and confirm it isn't on disk.
  66. process.env.CEREBRAS_API_KEY = 'sk-super-secret-value';
  67. expect(fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8'))
  68. .not.toContain('sk-super-secret-value');
  69. });
  70. it('resolves the API key from the configured env var at call time', () => {
  71. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
  72. expect(resolveOffload().apiKey).toBeUndefined(); // env var not set yet
  73. process.env.CEREBRAS_API_KEY = 'sk-live';
  74. const c = resolveOffload();
  75. expect(c.enabled).toBe(true);
  76. expect(c.apiKey).toBe('sk-live');
  77. expect(c.keySource).toBe('CEREBRAS_API_KEY');
  78. expect(c.origin).toBe('config');
  79. });
  80. it('clears the offload block on disable, leaving other config keys intact', () => {
  81. const cfgPath = path.join(home, '.codegraph', 'config.json');
  82. fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
  83. fs.writeFileSync(cfgPath, JSON.stringify({ somethingElse: 1, offload: { url: 'x' } }));
  84. writeOffloadConfig(null);
  85. const after = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  86. expect(after.offload).toBeUndefined();
  87. expect(after.somethingElse).toBe(1);
  88. });
  89. });
  90. describe('env overrides config', () => {
  91. it('lets CODEGRAPH_OFFLOAD_URL override the file and report origin=env', () => {
  92. writeOffloadConfig({ url: 'https://file.example/v1' });
  93. process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
  94. const c = resolveOffload();
  95. expect(c.url).toBe('https://env.example/v1');
  96. expect(c.origin).toBe('env');
  97. });
  98. it('reads the key directly from CODEGRAPH_OFFLOAD_KEY when set', () => {
  99. process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
  100. process.env.CODEGRAPH_OFFLOAD_KEY = 'sk-direct';
  101. const c = resolveOffload();
  102. expect(c.apiKey).toBe('sk-direct');
  103. expect(c.keySource).toBe('CODEGRAPH_OFFLOAD_KEY');
  104. });
  105. });
  106. describe('strict degradation (never throws, returns null to fall back)', () => {
  107. it('returns null when no endpoint is configured', async () => {
  108. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  109. });
  110. it('returns null when the upstream request rejects', async () => {
  111. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
  112. vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
  113. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  114. });
  115. it('returns null on a non-2xx response', async () => {
  116. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
  117. vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  118. ok: false, status: 500, text: async () => 'boom',
  119. }));
  120. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  121. });
  122. it('returns null when the model returns an empty answer', async () => {
  123. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
  124. vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  125. ok: true, status: 200, json: async () => ({ choices: [{ message: { content: ' ' } }] }),
  126. }));
  127. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  128. });
  129. });
  130. describe('success path', () => {
  131. it('returns the synthesized answer (with the plain footer) and posts an OpenAI-compatible body with the key', async () => {
  132. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', model: 'gpt-oss-120b', keyEnv: 'CEREBRAS_API_KEY' });
  133. process.env.CEREBRAS_API_KEY = 'sk-live';
  134. const fetchMock = vi.fn().mockResolvedValue({
  135. ok: true, status: 200,
  136. json: async () => ({ choices: [{ message: { content: 'Coverage: full.\nThe answer.' }, finish_reason: 'stop' }] }),
  137. });
  138. vi.stubGlobal('fetch', fetchMock);
  139. const out = await synthesizeOffload({ query: 'how does X work', context: 'source here' });
  140. expect(out).toContain('Coverage: full.');
  141. expect(out).toContain('Synthesized by CodeGraph'); // plain footer present
  142. const [calledUrl, init] = fetchMock.mock.calls[0];
  143. expect(calledUrl).toBe('https://api.cerebras.ai/v1/chat/completions');
  144. expect((init.headers as Record<string, string>).authorization).toBe('Bearer sk-live');
  145. const body = JSON.parse(init.body as string);
  146. expect(body.model).toBe('gpt-oss-120b');
  147. expect(body.messages[1].content).toContain('source here');
  148. expect(body.messages[1].content).toContain('how does X work');
  149. });
  150. });
  151. describe('stripAgentDirectives', () => {
  152. it('drops the agent-directed header but keeps source sections', () => {
  153. const ctx = [
  154. '## Exploration: how does X work',
  155. 'Found 12 symbols across 3 files.',
  156. '',
  157. '#### src/a.ts — foo(function)',
  158. 'code body',
  159. ].join('\n');
  160. const stripped = stripAgentDirectives(ctx);
  161. expect(stripped).not.toContain('## Exploration:');
  162. expect(stripped).not.toContain('Found 12 symbols');
  163. expect(stripped).toContain('#### src/a.ts');
  164. expect(stripped).toContain('code body');
  165. });
  166. });
  167. });