1
0

offload.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  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. MANAGED_DEFAULT_URL,
  22. MANAGED_DEFAULT_MODEL,
  23. } from '../src/reasoning/config';
  24. import { readOffloadToken, writeOffloadToken } from '../src/reasoning/credentials';
  25. import { isOffloadEnabled, synthesizeOffload, stripAgentDirectives } from '../src/reasoning/reasoner';
  26. describe('reasoning offload', () => {
  27. let home: string;
  28. // Point ~/.codegraph at a throwaway dir (os.homedir() honors $HOME on POSIX,
  29. // $USERPROFILE on Windows) + start from a clean env each test.
  30. const HOME_ENV = ['HOME', 'USERPROFILE'];
  31. const OFFLOAD_ENV = [
  32. 'CODEGRAPH_OFFLOAD_URL', 'CODEGRAPH_OFFLOAD_MODEL', 'CODEGRAPH_OFFLOAD_KEY',
  33. 'CODEGRAPH_OFFLOAD_EFFORT', 'CODEGRAPH_OFFLOAD_STYLE', 'CODEGRAPH_OFFLOAD_TIMEOUT_MS',
  34. 'CODEGRAPH_OFFLOAD_MAXTOKENS', 'CODEGRAPH_OFFLOAD_STRIP', 'CODEGRAPH_OFFLOAD_DEBUG',
  35. 'CODEGRAPH_OFFLOAD_DISABLE', 'CODEGRAPH_OFFLOAD_USAGE_LOG', 'CEREBRAS_API_KEY',
  36. ];
  37. let saved: Record<string, string | undefined>;
  38. beforeEach(() => {
  39. home = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-offload-'));
  40. saved = {};
  41. for (const k of [...HOME_ENV, ...OFFLOAD_ENV]) { saved[k] = process.env[k]; delete process.env[k]; }
  42. process.env.HOME = home;
  43. process.env.USERPROFILE = home;
  44. });
  45. afterEach(() => {
  46. for (const k of [...HOME_ENV, ...OFFLOAD_ENV]) {
  47. if (saved[k] === undefined) delete process.env[k];
  48. else process.env[k] = saved[k];
  49. }
  50. vi.restoreAllMocks();
  51. if (fs.existsSync(home)) fs.rmSync(home, { recursive: true, force: true });
  52. });
  53. describe('config persistence', () => {
  54. it('is off, with sensible defaults, when nothing is configured', () => {
  55. const c = resolveOffload();
  56. expect(c.enabled).toBe(false);
  57. expect(c.origin).toBe('none');
  58. expect(c.model).toBe('gpt-oss-120b');
  59. expect(c.effort).toBe('low');
  60. expect(c.style).toBe('plain');
  61. expect(isOffloadEnabled()).toBe(false);
  62. });
  63. it('round-trips the config block and never writes the API key to disk', () => {
  64. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', model: 'gpt-oss-120b', keyEnv: 'CEREBRAS_API_KEY' });
  65. expect(readOffloadConfig().url).toBe('https://api.cerebras.ai/v1');
  66. const raw = fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8');
  67. expect(raw).toContain('CEREBRAS_API_KEY'); // the env var NAME is stored
  68. // ...but no actual secret material. Set a key and confirm it isn't on disk.
  69. process.env.CEREBRAS_API_KEY = 'sk-super-secret-value';
  70. expect(fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8'))
  71. .not.toContain('sk-super-secret-value');
  72. });
  73. it('resolves the API key from the configured env var at call time', () => {
  74. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
  75. expect(resolveOffload().apiKey).toBeUndefined(); // env var not set yet
  76. process.env.CEREBRAS_API_KEY = 'sk-live';
  77. const c = resolveOffload();
  78. expect(c.enabled).toBe(true);
  79. expect(c.apiKey).toBe('sk-live');
  80. expect(c.keySource).toBe('CEREBRAS_API_KEY');
  81. expect(c.origin).toBe('config');
  82. });
  83. it('clears the offload block on disable, leaving other config keys intact', () => {
  84. const cfgPath = path.join(home, '.codegraph', 'config.json');
  85. fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
  86. fs.writeFileSync(cfgPath, JSON.stringify({ somethingElse: 1, offload: { url: 'x' } }));
  87. writeOffloadConfig(null);
  88. const after = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  89. expect(after.offload).toBeUndefined();
  90. expect(after.somethingElse).toBe(1);
  91. });
  92. });
  93. describe('env overrides config', () => {
  94. it('lets CODEGRAPH_OFFLOAD_URL override the file and report origin=env', () => {
  95. writeOffloadConfig({ url: 'https://file.example/v1' });
  96. process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
  97. const c = resolveOffload();
  98. expect(c.url).toBe('https://env.example/v1');
  99. expect(c.origin).toBe('env');
  100. });
  101. it('reads the key directly from CODEGRAPH_OFFLOAD_KEY when set', () => {
  102. process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
  103. process.env.CODEGRAPH_OFFLOAD_KEY = 'sk-direct';
  104. const c = resolveOffload();
  105. expect(c.apiKey).toBe('sk-direct');
  106. expect(c.keySource).toBe('CODEGRAPH_OFFLOAD_KEY');
  107. });
  108. });
  109. describe('CODEGRAPH_OFFLOAD_DISABLE kill-switch', () => {
  110. it('forces the offload off even when managed + signed in', () => {
  111. writeOffloadConfig({ managed: true });
  112. writeOffloadToken('cgai_live');
  113. expect(resolveOffload().enabled).toBe(true); // sanity: on without the flag
  114. process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
  115. const c = resolveOffload();
  116. expect(c.enabled).toBe(false);
  117. expect(c.managed).toBe(false);
  118. expect(c.origin).toBe('none');
  119. expect(isOffloadEnabled()).toBe(false);
  120. });
  121. it('forces the offload off even with a BYO endpoint + key', () => {
  122. process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
  123. process.env.CODEGRAPH_OFFLOAD_KEY = 'sk-direct';
  124. expect(resolveOffload().enabled).toBe(true);
  125. process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
  126. expect(resolveOffload().enabled).toBe(false);
  127. });
  128. });
  129. describe('per-call usage log (CODEGRAPH_OFFLOAD_USAGE_LOG)', () => {
  130. const okResponse = () => ({
  131. ok: true, status: 200,
  132. headers: { get: (h: string) => (h === 'x-cg-credits-charged' ? '127' : null) },
  133. json: async () => ({
  134. choices: [{ message: { content: 'Coverage: full.\nThe answer.' }, finish_reason: 'stop' }],
  135. usage: { prompt_tokens: 700, completion_tokens: 80, total_tokens: 780 },
  136. }),
  137. });
  138. it('appends one JSON line with tokens + charged credits when the log path is set', async () => {
  139. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
  140. process.env.CEREBRAS_API_KEY = 'sk-live';
  141. vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse()));
  142. const logPath = path.join(home, 'usage.jsonl');
  143. process.env.CODEGRAPH_OFFLOAD_USAGE_LOG = logPath;
  144. await synthesizeOffload({ query: 'q', context: 'src' });
  145. const line = JSON.parse(fs.readFileSync(logPath, 'utf8').trim());
  146. expect(line.totalTokens).toBe(780);
  147. expect(line.promptTokens).toBe(700);
  148. expect(line.creditsCharged).toBe(127);
  149. expect(line.costUsd).toBeCloseTo(0.00127, 6); // 100k credits = $1
  150. expect(line.answerLen).toBeGreaterThan(0);
  151. });
  152. it('is a no-op (and never throws) when the log path is unset', async () => {
  153. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
  154. process.env.CEREBRAS_API_KEY = 'sk-live';
  155. vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse()));
  156. // no CODEGRAPH_OFFLOAD_USAGE_LOG set → answer still returns fine
  157. const out = await synthesizeOffload({ query: 'q', context: 'src' });
  158. expect(out).toContain('Coverage: full.');
  159. });
  160. });
  161. describe('strict degradation (never throws, returns null to fall back)', () => {
  162. it('returns null when no endpoint is configured', async () => {
  163. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  164. });
  165. it('returns null when the upstream request rejects', async () => {
  166. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
  167. vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
  168. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  169. });
  170. it('returns null on a non-2xx response', async () => {
  171. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
  172. vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  173. ok: false, status: 500, text: async () => 'boom',
  174. }));
  175. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  176. });
  177. it('returns null when the model returns an empty answer', async () => {
  178. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
  179. vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  180. ok: true, status: 200, json: async () => ({ choices: [{ message: { content: ' ' } }] }),
  181. }));
  182. expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
  183. });
  184. });
  185. describe('success path', () => {
  186. it('returns the synthesized answer (with the plain footer) and posts an OpenAI-compatible body with the key', async () => {
  187. writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', model: 'gpt-oss-120b', keyEnv: 'CEREBRAS_API_KEY' });
  188. process.env.CEREBRAS_API_KEY = 'sk-live';
  189. const fetchMock = vi.fn().mockResolvedValue({
  190. ok: true, status: 200,
  191. json: async () => ({ choices: [{ message: { content: 'Coverage: full.\nThe answer.' }, finish_reason: 'stop' }] }),
  192. });
  193. vi.stubGlobal('fetch', fetchMock);
  194. const out = await synthesizeOffload({ query: 'how does X work', context: 'source here' });
  195. expect(out).toContain('Coverage: full.');
  196. expect(out).toContain('Synthesized by CodeGraph'); // plain footer present
  197. const [calledUrl, init] = fetchMock.mock.calls[0];
  198. expect(calledUrl).toBe('https://api.cerebras.ai/v1/chat/completions');
  199. expect((init.headers as Record<string, string>).authorization).toBe('Bearer sk-live');
  200. const body = JSON.parse(init.body as string);
  201. expect(body.model).toBe('gpt-oss-120b');
  202. expect(body.messages[1].content).toContain('source here');
  203. expect(body.messages[1].content).toContain('how does X work');
  204. });
  205. });
  206. describe('stripAgentDirectives', () => {
  207. it('drops the agent-directed header but keeps source sections', () => {
  208. const ctx = [
  209. '## Exploration: how does X work',
  210. 'Found 12 symbols across 3 files.',
  211. '',
  212. '#### src/a.ts — foo(function)',
  213. 'code body',
  214. ].join('\n');
  215. const stripped = stripAgentDirectives(ctx);
  216. expect(stripped).not.toContain('## Exploration:');
  217. expect(stripped).not.toContain('Found 12 symbols');
  218. expect(stripped).toContain('#### src/a.ts');
  219. expect(stripped).toContain('code body');
  220. });
  221. });
  222. describe('managed tier (CodeGraph AI)', () => {
  223. it('stores the org token at 0600 in credentials.json, not in config.json', () => {
  224. writeOffloadConfig({ managed: true });
  225. writeOffloadToken('cgai_secrettoken');
  226. expect(readOffloadToken()).toBe('cgai_secrettoken');
  227. // config.json carries the managed flag but NOT the token.
  228. const cfg = fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8');
  229. expect(cfg).toContain('managed');
  230. expect(cfg).not.toContain('cgai_secrettoken');
  231. const credPath = path.join(home, '.codegraph', 'credentials.json');
  232. expect(fs.readFileSync(credPath, 'utf8')).toContain('cgai_secrettoken');
  233. // POSIX perms must be owner-only (0600). (Windows has no POSIX mode bits.)
  234. if (process.platform !== 'win32') {
  235. expect(fs.statSync(credPath).mode & 0o777).toBe(0o600);
  236. }
  237. });
  238. it('resolves managed mode to the gateway URL + public model id + login token', () => {
  239. writeOffloadConfig({ managed: true });
  240. writeOffloadToken('cgai_live');
  241. const c = resolveOffload();
  242. expect(c.enabled).toBe(true);
  243. expect(c.managed).toBe(true);
  244. expect(c.url).toBe(MANAGED_DEFAULT_URL);
  245. expect(c.model).toBe(MANAGED_DEFAULT_MODEL);
  246. expect(c.apiKey).toBe('cgai_live');
  247. expect(c.keySource).toBe('codegraph login');
  248. });
  249. it('is NOT enabled when managed but signed out (no token)', () => {
  250. writeOffloadConfig({ managed: true });
  251. const c = resolveOffload();
  252. expect(c.managed).toBe(true);
  253. expect(c.enabled).toBe(false); // url defaults, but no token → effectively logged out
  254. expect(isOffloadEnabled()).toBe(false);
  255. });
  256. it('clears the token on logout', () => {
  257. writeOffloadToken('cgai_live');
  258. writeOffloadToken(null);
  259. expect(readOffloadToken()).toBeUndefined();
  260. });
  261. it('lets env override the managed endpoint and token (for testing)', () => {
  262. writeOffloadConfig({ managed: true });
  263. writeOffloadToken('cgai_stored');
  264. process.env.CODEGRAPH_OFFLOAD_URL = 'http://localhost:8787/v1';
  265. process.env.CODEGRAPH_OFFLOAD_KEY = 'cgai_env';
  266. const c = resolveOffload();
  267. expect(c.url).toBe('http://localhost:8787/v1');
  268. expect(c.apiKey).toBe('cgai_env');
  269. expect(c.keySource).toBe('CODEGRAPH_OFFLOAD_KEY');
  270. });
  271. });
  272. });