guardlink 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +11 -2
  3. package/dist/agents/config.d.ts +17 -0
  4. package/dist/agents/config.d.ts.map +1 -1
  5. package/dist/agents/config.js +38 -4
  6. package/dist/agents/config.js.map +1 -1
  7. package/dist/agents/index.d.ts +5 -1
  8. package/dist/agents/index.d.ts.map +1 -1
  9. package/dist/agents/index.js +4 -1
  10. package/dist/agents/index.js.map +1 -1
  11. package/dist/agents/launcher.d.ts +25 -8
  12. package/dist/agents/launcher.d.ts.map +1 -1
  13. package/dist/agents/launcher.js +137 -9
  14. package/dist/agents/launcher.js.map +1 -1
  15. package/dist/agents/prompts.d.ts +9 -0
  16. package/dist/agents/prompts.d.ts.map +1 -1
  17. package/dist/agents/prompts.js +43 -6
  18. package/dist/agents/prompts.js.map +1 -1
  19. package/dist/analyze/index.d.ts +44 -8
  20. package/dist/analyze/index.d.ts.map +1 -1
  21. package/dist/analyze/index.js +291 -15
  22. package/dist/analyze/index.js.map +1 -1
  23. package/dist/analyze/llm.d.ts +65 -13
  24. package/dist/analyze/llm.d.ts.map +1 -1
  25. package/dist/analyze/llm.js +429 -107
  26. package/dist/analyze/llm.js.map +1 -1
  27. package/dist/analyze/prompts.d.ts +6 -2
  28. package/dist/analyze/prompts.d.ts.map +1 -1
  29. package/dist/analyze/prompts.js +230 -111
  30. package/dist/analyze/prompts.js.map +1 -1
  31. package/dist/analyze/tools.d.ts +28 -0
  32. package/dist/analyze/tools.d.ts.map +1 -0
  33. package/dist/analyze/tools.js +236 -0
  34. package/dist/analyze/tools.js.map +1 -0
  35. package/dist/analyzer/index.d.ts +3 -0
  36. package/dist/analyzer/index.d.ts.map +1 -1
  37. package/dist/analyzer/index.js +3 -0
  38. package/dist/analyzer/index.js.map +1 -1
  39. package/dist/analyzer/sarif.d.ts +5 -6
  40. package/dist/analyzer/sarif.d.ts.map +1 -1
  41. package/dist/analyzer/sarif.js +5 -6
  42. package/dist/analyzer/sarif.js.map +1 -1
  43. package/dist/cli/index.d.ts +27 -16
  44. package/dist/cli/index.d.ts.map +1 -1
  45. package/dist/cli/index.js +524 -105
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/dashboard/data.d.ts +5 -0
  48. package/dist/dashboard/data.d.ts.map +1 -1
  49. package/dist/dashboard/data.js +5 -0
  50. package/dist/dashboard/data.js.map +1 -1
  51. package/dist/dashboard/generate.d.ts +8 -5
  52. package/dist/dashboard/generate.d.ts.map +1 -1
  53. package/dist/dashboard/generate.js +206 -66
  54. package/dist/dashboard/generate.js.map +1 -1
  55. package/dist/dashboard/index.d.ts +5 -0
  56. package/dist/dashboard/index.d.ts.map +1 -1
  57. package/dist/dashboard/index.js +5 -0
  58. package/dist/dashboard/index.js.map +1 -1
  59. package/dist/diff/git.d.ts +10 -7
  60. package/dist/diff/git.d.ts.map +1 -1
  61. package/dist/diff/git.js +10 -7
  62. package/dist/diff/git.js.map +1 -1
  63. package/dist/diff/index.d.ts +4 -0
  64. package/dist/diff/index.d.ts.map +1 -1
  65. package/dist/diff/index.js +4 -0
  66. package/dist/diff/index.js.map +1 -1
  67. package/dist/init/detect.d.ts +5 -0
  68. package/dist/init/detect.d.ts.map +1 -1
  69. package/dist/init/detect.js +5 -0
  70. package/dist/init/detect.js.map +1 -1
  71. package/dist/init/index.d.ts +26 -6
  72. package/dist/init/index.d.ts.map +1 -1
  73. package/dist/init/index.js +91 -11
  74. package/dist/init/index.js.map +1 -1
  75. package/dist/init/picker.d.ts.map +1 -1
  76. package/dist/init/picker.js +17 -6
  77. package/dist/init/picker.js.map +1 -1
  78. package/dist/init/templates.d.ts +20 -0
  79. package/dist/init/templates.d.ts.map +1 -1
  80. package/dist/init/templates.js +167 -36
  81. package/dist/init/templates.js.map +1 -1
  82. package/dist/mcp/index.d.ts +5 -0
  83. package/dist/mcp/index.d.ts.map +1 -1
  84. package/dist/mcp/index.js +5 -0
  85. package/dist/mcp/index.js.map +1 -1
  86. package/dist/mcp/lookup.d.ts +5 -0
  87. package/dist/mcp/lookup.d.ts.map +1 -1
  88. package/dist/mcp/lookup.js +5 -0
  89. package/dist/mcp/lookup.js.map +1 -1
  90. package/dist/mcp/server.d.ts +16 -13
  91. package/dist/mcp/server.d.ts.map +1 -1
  92. package/dist/mcp/server.js +140 -17
  93. package/dist/mcp/server.js.map +1 -1
  94. package/dist/mcp/suggest.d.ts +8 -6
  95. package/dist/mcp/suggest.d.ts.map +1 -1
  96. package/dist/mcp/suggest.js +8 -6
  97. package/dist/mcp/suggest.js.map +1 -1
  98. package/dist/parser/clear.d.ts +36 -0
  99. package/dist/parser/clear.d.ts.map +1 -0
  100. package/dist/parser/clear.js +148 -0
  101. package/dist/parser/clear.js.map +1 -0
  102. package/dist/parser/index.d.ts +3 -1
  103. package/dist/parser/index.d.ts.map +1 -1
  104. package/dist/parser/index.js +2 -1
  105. package/dist/parser/index.js.map +1 -1
  106. package/dist/parser/parse-file.d.ts +5 -2
  107. package/dist/parser/parse-file.d.ts.map +1 -1
  108. package/dist/parser/parse-file.js +29 -2
  109. package/dist/parser/parse-file.js.map +1 -1
  110. package/dist/parser/parse-line.d.ts +3 -3
  111. package/dist/parser/parse-line.js +3 -3
  112. package/dist/parser/parse-project.d.ts +7 -7
  113. package/dist/parser/parse-project.d.ts.map +1 -1
  114. package/dist/parser/parse-project.js +24 -11
  115. package/dist/parser/parse-project.js.map +1 -1
  116. package/dist/parser/validate.d.ts +12 -0
  117. package/dist/parser/validate.d.ts.map +1 -1
  118. package/dist/parser/validate.js +44 -0
  119. package/dist/parser/validate.js.map +1 -1
  120. package/dist/report/index.d.ts +3 -0
  121. package/dist/report/index.d.ts.map +1 -1
  122. package/dist/report/index.js +3 -0
  123. package/dist/report/index.js.map +1 -1
  124. package/dist/report/report.d.ts +4 -7
  125. package/dist/report/report.d.ts.map +1 -1
  126. package/dist/report/report.js +68 -7
  127. package/dist/report/report.js.map +1 -1
  128. package/dist/review/index.d.ts +62 -0
  129. package/dist/review/index.d.ts.map +1 -0
  130. package/dist/review/index.js +226 -0
  131. package/dist/review/index.js.map +1 -0
  132. package/dist/tui/commands.d.ts +26 -1
  133. package/dist/tui/commands.d.ts.map +1 -1
  134. package/dist/tui/commands.js +608 -101
  135. package/dist/tui/commands.js.map +1 -1
  136. package/dist/tui/config.d.ts +6 -0
  137. package/dist/tui/config.d.ts.map +1 -1
  138. package/dist/tui/config.js +6 -0
  139. package/dist/tui/config.js.map +1 -1
  140. package/dist/tui/format.d.ts +7 -0
  141. package/dist/tui/format.d.ts.map +1 -1
  142. package/dist/tui/format.js +59 -0
  143. package/dist/tui/format.js.map +1 -1
  144. package/dist/tui/index.d.ts +8 -8
  145. package/dist/tui/index.d.ts.map +1 -1
  146. package/dist/tui/index.js +47 -10
  147. package/dist/tui/index.js.map +1 -1
  148. package/dist/tui/input.d.ts +6 -0
  149. package/dist/tui/input.d.ts.map +1 -1
  150. package/dist/tui/input.js +6 -0
  151. package/dist/tui/input.js.map +1 -1
  152. package/dist/types/index.d.ts +2 -0
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/package.json +1 -1
@@ -2,67 +2,63 @@
2
2
  * GuardLink Threat Reports — Lightweight LLM client using raw fetch.
3
3
  *
4
4
  * Supports:
5
- * - Anthropic Messages API (claude-sonnet-4-5-20250929, etc.)
6
- * - OpenAI-compatible Chat Completions (GPT-4o, DeepSeek, OpenRouter)
5
+ * - Anthropic Messages API (claude-sonnet-4-6, claude-opus-4-6, etc.) with extended thinking + tool use
6
+ * - OpenAI Responses API (gpt-5.2, o3, etc.) with web search, tools, structured output
7
+ * - Google Gemini API (gemini-2.5-flash, gemini-3-pro, etc.) via OpenAI-compatible endpoint
8
+ * - OpenAI-compatible Chat Completions (DeepSeek, OpenRouter, Ollama)
9
+ * - DeepSeek reasoning mode (deepseek-reasoner)
7
10
  *
8
11
  * Zero dependencies — uses Node 20+ built-in fetch.
9
12
  *
10
- * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "Reads API keys from environment variables"
11
- * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "Makes HTTP requests to configurable provider URLs"
12
- * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "Sends threat model content as LLM prompt"
13
- * @accepts #prompt-injection on #llm-client -- "Core feature: threat model data is sent to LLM for analysis"
14
- * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded to known providers"
15
- * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys read from env, not logged"
16
- * @handles secrets on #llm-client -- "API keys held in memory during request lifecycle"
17
- * @boundary between #llm-client and External_LLM_APIs (#llm-boundary) -- "HTTP requests cross network trust boundary to external AI providers"
18
- * @flows #llm-client -> External_LLM_APIs via fetch -- "HTTP POST with auth headers and prompt payload"
19
- * @flows External_LLM_APIs -> #llm-client via response -- "Streaming or complete response from LLM provider"
13
+ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints"
14
+ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config"
15
+ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers"
16
+ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API"
17
+ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API"
18
+ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only"
19
+ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input"
20
+ * @flows #llm-client -> LLMProvider via fetch -- "API request output"
21
+ * @flows LLMProvider -> #llm-client via response -- "API response input"
22
+ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call"
23
+ * @handles secrets on #llm-client -- "Processes API keys for authentication"
20
24
  */
25
+ // ─── Defaults ────────────────────────────────────────────────────────
21
26
  const DEFAULT_MODELS = {
22
- anthropic: 'claude-sonnet-4-5-20250929',
23
- openai: 'gpt-4o',
24
- openrouter: 'anthropic/claude-sonnet-4-5-20250929',
27
+ anthropic: 'claude-sonnet-4-6',
28
+ openai: 'gpt-5.2',
29
+ google: 'gemini-2.5-flash',
30
+ openrouter: 'anthropic/claude-sonnet-4-6',
25
31
  deepseek: 'deepseek-chat',
32
+ ollama: 'llama3.2',
26
33
  };
27
34
  const BASE_URLS = {
28
35
  anthropic: 'https://api.anthropic.com',
29
36
  openai: 'https://api.openai.com',
37
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai',
30
38
  openrouter: 'https://openrouter.ai/api',
31
39
  deepseek: 'https://api.deepseek.com',
40
+ ollama: 'http://localhost:11434',
32
41
  };
42
+ // ─── Auto-detect ─────────────────────────────────────────────────────
33
43
  /**
34
44
  * Auto-detect provider from environment variables.
35
45
  * Returns null if no API key found.
36
46
  */
37
47
  export function autoDetectConfig() {
38
- // Priority: Anthropic > OpenAI > OpenRouter > DeepSeek
39
48
  if (process.env.ANTHROPIC_API_KEY) {
40
- return {
41
- provider: 'anthropic',
42
- model: DEFAULT_MODELS.anthropic,
43
- apiKey: process.env.ANTHROPIC_API_KEY,
44
- };
49
+ return { provider: 'anthropic', model: DEFAULT_MODELS.anthropic, apiKey: process.env.ANTHROPIC_API_KEY };
45
50
  }
46
51
  if (process.env.OPENAI_API_KEY) {
47
- return {
48
- provider: 'openai',
49
- model: DEFAULT_MODELS.openai,
50
- apiKey: process.env.OPENAI_API_KEY,
51
- };
52
+ return { provider: 'openai', model: DEFAULT_MODELS.openai, apiKey: process.env.OPENAI_API_KEY };
52
53
  }
53
54
  if (process.env.OPENROUTER_API_KEY) {
54
- return {
55
- provider: 'openrouter',
56
- model: DEFAULT_MODELS.openrouter,
57
- apiKey: process.env.OPENROUTER_API_KEY,
58
- };
55
+ return { provider: 'openrouter', model: DEFAULT_MODELS.openrouter, apiKey: process.env.OPENROUTER_API_KEY };
56
+ }
57
+ if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) {
58
+ return { provider: 'google', model: DEFAULT_MODELS.google, apiKey: (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) };
59
59
  }
60
60
  if (process.env.DEEPSEEK_API_KEY) {
61
- return {
62
- provider: 'deepseek',
63
- model: DEFAULT_MODELS.deepseek,
64
- apiKey: process.env.DEEPSEEK_API_KEY,
65
- };
61
+ return { provider: 'deepseek', model: DEFAULT_MODELS.deepseek, apiKey: process.env.DEEPSEEK_API_KEY };
66
62
  }
67
63
  return null;
68
64
  }
@@ -70,13 +66,13 @@ export function autoDetectConfig() {
70
66
  * Build config from explicit flags + env vars.
71
67
  */
72
68
  export function buildConfig(opts) {
73
- // If provider specified, use it
74
69
  if (opts.provider) {
75
70
  const provider = opts.provider;
76
71
  const envKeyMap = {
77
72
  anthropic: 'ANTHROPIC_API_KEY',
78
73
  openai: 'OPENAI_API_KEY',
79
74
  openrouter: 'OPENROUTER_API_KEY',
75
+ google: 'GOOGLE_API_KEY',
80
76
  deepseek: 'DEEPSEEK_API_KEY',
81
77
  };
82
78
  const apiKey = opts.apiKey || process.env[envKeyMap[provider] || ''];
@@ -84,58 +80,120 @@ export function buildConfig(opts) {
84
80
  return null;
85
81
  return {
86
82
  provider,
87
- model: opts.model || DEFAULT_MODELS[provider] || 'gpt-4o',
83
+ model: opts.model || DEFAULT_MODELS[provider] || 'gpt-5.2',
88
84
  apiKey,
89
85
  };
90
86
  }
91
- // Auto-detect
92
87
  const config = autoDetectConfig();
93
88
  if (!config)
94
89
  return null;
95
- // Override model if specified
96
90
  if (opts.model)
97
91
  config.model = opts.model;
98
92
  return config;
99
93
  }
94
+ // ─── Main entry point ────────────────────────────────────────────────
100
95
  /**
101
96
  * Send a message to the LLM and return the response.
97
+ * Supports streaming, tool use (agentic loop), extended thinking,
98
+ * web search, and structured output.
102
99
  */
103
100
  export async function chatCompletion(config, systemPrompt, userMessage, onChunk) {
104
101
  if (config.provider === 'anthropic') {
105
- return callAnthropic(config, systemPrompt, userMessage, onChunk);
102
+ return callAnthropicWithTools(config, systemPrompt, userMessage, onChunk);
103
+ }
104
+ else if (config.provider === 'openai') {
105
+ return callOpenAIResponses(config, systemPrompt, userMessage, onChunk);
106
106
  }
107
107
  else {
108
+ // Google Gemini, DeepSeek, OpenRouter, Ollama all use OpenAI-compatible Chat Completions
108
109
  return callOpenAICompatible(config, systemPrompt, userMessage, onChunk);
109
110
  }
110
111
  }
111
- // ─── Anthropic Messages API ──────────────────────────────────────────
112
- async function callAnthropic(config, systemPrompt, userMessage, onChunk) {
112
+ // ─── Anthropic Messages API (2025) ──────────────────────────────────
113
+ const ANTHROPIC_API_VERSION = '2025-04-14';
114
+ /** Wrapper with agentic tool-call loop */
115
+ async function callAnthropicWithTools(config, systemPrompt, userMessage, onChunk) {
116
+ const maxRounds = config.maxToolRounds ?? 5;
117
+ let messages = [{ role: 'user', content: userMessage }];
118
+ const allToolCalls = [];
119
+ let finalResponse = null;
120
+ for (let round = 0; round <= maxRounds; round++) {
121
+ const response = await callAnthropic(config, systemPrompt, messages, round === 0 ? onChunk : undefined);
122
+ if (response.toolCalls?.length)
123
+ allToolCalls.push(...response.toolCalls);
124
+ if (!response.toolCalls?.length || !config.toolExecutor) {
125
+ finalResponse = response;
126
+ break;
127
+ }
128
+ // Add assistant response and tool results for next round
129
+ messages.push({ role: 'assistant', content: response._rawContent });
130
+ for (const tc of response.toolCalls) {
131
+ let resultText;
132
+ try {
133
+ resultText = await config.toolExecutor(tc.name, tc.arguments);
134
+ }
135
+ catch (err) {
136
+ resultText = `Error: ${err.message}`;
137
+ }
138
+ messages.push({
139
+ role: 'user',
140
+ content: [{ type: 'tool_result', tool_use_id: tc.id, content: resultText }],
141
+ });
142
+ }
143
+ }
144
+ if (!finalResponse)
145
+ throw new Error('Max tool call rounds exceeded');
146
+ finalResponse.toolCalls = allToolCalls.length ? allToolCalls : undefined;
147
+ return finalResponse;
148
+ }
149
+ async function callAnthropic(config, systemPrompt, messages, onChunk) {
113
150
  const baseUrl = config.baseUrl || BASE_URLS.anthropic;
114
151
  const maxTokens = config.maxTokens || 8192;
152
+ const headers = {
153
+ 'Content-Type': 'application/json',
154
+ 'x-api-key': config.apiKey,
155
+ 'anthropic-version': ANTHROPIC_API_VERSION,
156
+ };
157
+ if (config.extendedThinking) {
158
+ headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14';
159
+ }
160
+ const body = {
161
+ model: config.model,
162
+ max_tokens: maxTokens,
163
+ system: systemPrompt,
164
+ messages,
165
+ };
166
+ if (config.extendedThinking) {
167
+ body.thinking = { type: 'enabled', budget_tokens: config.thinkingBudget || 10000 };
168
+ }
169
+ if (config.tools?.length) {
170
+ body.tools = config.tools.map(t => ({
171
+ name: t.name,
172
+ description: t.description,
173
+ input_schema: {
174
+ type: 'object',
175
+ properties: t.parameters.properties,
176
+ required: t.parameters.required,
177
+ },
178
+ }));
179
+ }
115
180
  if (onChunk) {
116
- // Streaming
181
+ body.stream = true;
117
182
  const res = await fetch(`${baseUrl}/v1/messages`, {
118
- method: 'POST',
119
- headers: {
120
- 'Content-Type': 'application/json',
121
- 'x-api-key': config.apiKey,
122
- 'anthropic-version': '2023-06-01',
123
- },
124
- body: JSON.stringify({
125
- model: config.model,
126
- max_tokens: maxTokens,
127
- system: systemPrompt,
128
- stream: true,
129
- messages: [{ role: 'user', content: userMessage }],
130
- }),
183
+ method: 'POST', headers, body: JSON.stringify(body),
131
184
  });
132
185
  if (!res.ok) {
133
186
  const err = await res.text();
134
187
  throw new Error(`Anthropic API error ${res.status}: ${err}`);
135
188
  }
136
189
  let content = '';
190
+ let thinking = '';
137
191
  let inputTokens = 0;
138
192
  let outputTokens = 0;
193
+ const toolCalls = [];
194
+ let curToolId = '';
195
+ let curToolName = '';
196
+ let curToolArgs = '';
139
197
  const reader = res.body?.getReader();
140
198
  if (!reader)
141
199
  throw new Error('No response body');
@@ -155,52 +213,313 @@ async function callAnthropic(config, systemPrompt, userMessage, onChunk) {
155
213
  if (data === '[DONE]')
156
214
  continue;
157
215
  try {
158
- const event = JSON.parse(data);
159
- if (event.type === 'content_block_delta' && event.delta?.text) {
160
- content += event.delta.text;
161
- onChunk(event.delta.text);
216
+ const ev = JSON.parse(data);
217
+ if (ev.type === 'content_block_start' && ev.content_block?.type === 'tool_use') {
218
+ curToolId = ev.content_block.id || '';
219
+ curToolName = ev.content_block.name || '';
220
+ curToolArgs = '';
162
221
  }
163
- if (event.type === 'message_delta' && event.usage) {
164
- outputTokens = event.usage.output_tokens || 0;
222
+ if (ev.type === 'content_block_delta') {
223
+ if (ev.delta?.type === 'text_delta' && ev.delta?.text) {
224
+ content += ev.delta.text;
225
+ onChunk(ev.delta.text);
226
+ }
227
+ if (ev.delta?.type === 'thinking_delta' && ev.delta?.thinking) {
228
+ thinking += ev.delta.thinking;
229
+ }
230
+ if (ev.delta?.type === 'input_json_delta' && ev.delta?.partial_json) {
231
+ curToolArgs += ev.delta.partial_json;
232
+ }
165
233
  }
166
- if (event.type === 'message_start' && event.message?.usage) {
167
- inputTokens = event.message.usage.input_tokens || 0;
234
+ if (ev.type === 'content_block_stop' && curToolId) {
235
+ try {
236
+ toolCalls.push({ id: curToolId, name: curToolName, arguments: JSON.parse(curToolArgs || '{}') });
237
+ }
238
+ catch { /* skip */ }
239
+ curToolId = '';
240
+ curToolName = '';
241
+ curToolArgs = '';
168
242
  }
243
+ if (ev.type === 'message_delta' && ev.usage)
244
+ outputTokens = ev.usage.output_tokens || 0;
245
+ if (ev.type === 'message_start' && ev.message?.usage)
246
+ inputTokens = ev.message.usage.input_tokens || 0;
169
247
  }
170
- catch { /* skip non-JSON lines */ }
248
+ catch { /* skip */ }
171
249
  }
172
250
  }
173
- return { content, model: config.model, inputTokens, outputTokens };
251
+ return {
252
+ content, model: config.model, inputTokens, outputTokens,
253
+ thinking: thinking || undefined, thinkingTokens: undefined,
254
+ toolCalls: toolCalls.length ? toolCalls : undefined,
255
+ _rawContent: buildRawContent(content, thinking, toolCalls),
256
+ };
174
257
  }
175
258
  else {
176
259
  // Non-streaming
177
260
  const res = await fetch(`${baseUrl}/v1/messages`, {
178
- method: 'POST',
179
- headers: {
180
- 'Content-Type': 'application/json',
181
- 'x-api-key': config.apiKey,
182
- 'anthropic-version': '2023-06-01',
183
- },
184
- body: JSON.stringify({
185
- model: config.model,
186
- max_tokens: maxTokens,
187
- system: systemPrompt,
188
- messages: [{ role: 'user', content: userMessage }],
189
- }),
261
+ method: 'POST', headers, body: JSON.stringify(body),
190
262
  });
191
263
  if (!res.ok) {
192
264
  const err = await res.text();
193
265
  throw new Error(`Anthropic API error ${res.status}: ${err}`);
194
266
  }
195
267
  const data = await res.json();
268
+ let content = '';
269
+ let thinking = '';
270
+ const toolCalls = [];
271
+ for (const block of (data.content || [])) {
272
+ if (block.type === 'text')
273
+ content += block.text;
274
+ if (block.type === 'thinking')
275
+ thinking += block.thinking;
276
+ if (block.type === 'tool_use') {
277
+ toolCalls.push({ id: block.id, name: block.name, arguments: block.input || {} });
278
+ }
279
+ }
196
280
  return {
197
- content: data.content?.[0]?.text || '',
198
- model: data.model || config.model,
281
+ content, model: data.model || config.model,
199
282
  inputTokens: data.usage?.input_tokens,
200
283
  outputTokens: data.usage?.output_tokens,
284
+ thinking: thinking || undefined,
285
+ toolCalls: toolCalls.length ? toolCalls : undefined,
286
+ _rawContent: data.content,
201
287
  };
202
288
  }
203
289
  }
290
+ function buildRawContent(content, thinking, toolCalls) {
291
+ const blocks = [];
292
+ if (thinking)
293
+ blocks.push({ type: 'thinking', thinking });
294
+ if (content)
295
+ blocks.push({ type: 'text', text: content });
296
+ for (const tc of toolCalls)
297
+ blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.arguments });
298
+ return blocks;
299
+ }
300
+ // ─── OpenAI Responses API ────────────────────────────────────────────
301
+ async function callOpenAIResponses(config, systemPrompt, userMessage, onChunk) {
302
+ const baseUrl = config.baseUrl || BASE_URLS.openai;
303
+ const maxTokens = config.maxTokens || 8192;
304
+ const headers = {
305
+ 'Content-Type': 'application/json',
306
+ 'Authorization': `Bearer ${config.apiKey}`,
307
+ };
308
+ const input = [
309
+ { role: 'developer', content: systemPrompt },
310
+ { role: 'user', content: userMessage },
311
+ ];
312
+ const tools = [];
313
+ if (config.webSearch)
314
+ tools.push({ type: 'web_search' });
315
+ if (config.tools?.length) {
316
+ for (const t of config.tools) {
317
+ tools.push({
318
+ type: 'function', name: t.name, description: t.description,
319
+ parameters: t.parameters, strict: true,
320
+ });
321
+ }
322
+ }
323
+ const body = { model: config.model, input, max_output_tokens: maxTokens };
324
+ if (tools.length)
325
+ body.tools = tools;
326
+ if (config.responseFormat === 'json')
327
+ body.text = { format: { type: 'json_object' } };
328
+ if (onChunk) {
329
+ body.stream = true;
330
+ const res = await fetch(`${baseUrl}/v1/responses`, {
331
+ method: 'POST', headers, body: JSON.stringify(body),
332
+ });
333
+ if (!res.ok) {
334
+ const err = await res.text();
335
+ // Fallback to Chat Completions if Responses API not available
336
+ if (res.status === 404)
337
+ return callOpenAICompatible(config, systemPrompt, userMessage, onChunk);
338
+ throw new Error(`OpenAI API error ${res.status}: ${err}`);
339
+ }
340
+ let content = '';
341
+ let inputTokens = 0;
342
+ let outputTokens = 0;
343
+ const toolCalls = [];
344
+ const reader = res.body?.getReader();
345
+ if (!reader)
346
+ throw new Error('No response body');
347
+ const decoder = new TextDecoder();
348
+ let buffer = '';
349
+ while (true) {
350
+ const { done, value } = await reader.read();
351
+ if (done)
352
+ break;
353
+ buffer += decoder.decode(value, { stream: true });
354
+ const lines = buffer.split('\n');
355
+ buffer = lines.pop() || '';
356
+ for (const line of lines) {
357
+ if (!line.startsWith('data: '))
358
+ continue;
359
+ const d = line.slice(6).trim();
360
+ if (d === '[DONE]')
361
+ continue;
362
+ try {
363
+ const ev = JSON.parse(d);
364
+ if (ev.type === 'response.output_text.delta' && ev.delta) {
365
+ content += ev.delta;
366
+ onChunk(ev.delta);
367
+ }
368
+ if (ev.type === 'response.function_call_arguments.done') {
369
+ try {
370
+ toolCalls.push({ id: ev.call_id || '', name: ev.name || '', arguments: JSON.parse(ev.arguments || '{}') });
371
+ }
372
+ catch { /* skip */ }
373
+ }
374
+ if (ev.type === 'response.completed' && ev.response?.usage) {
375
+ inputTokens = ev.response.usage.input_tokens || 0;
376
+ outputTokens = ev.response.usage.output_tokens || 0;
377
+ }
378
+ }
379
+ catch { /* skip */ }
380
+ }
381
+ }
382
+ if (toolCalls.length && config.toolExecutor) {
383
+ return handleOpenAIToolLoop(config, baseUrl, headers, body, content, toolCalls, inputTokens, outputTokens, onChunk);
384
+ }
385
+ return { content, model: config.model, inputTokens, outputTokens, toolCalls: toolCalls.length ? toolCalls : undefined };
386
+ }
387
+ else {
388
+ // Non-streaming
389
+ const res = await fetch(`${baseUrl}/v1/responses`, {
390
+ method: 'POST', headers, body: JSON.stringify(body),
391
+ });
392
+ if (!res.ok) {
393
+ const err = await res.text();
394
+ if (res.status === 404)
395
+ return callOpenAICompatible(config, systemPrompt, userMessage, undefined);
396
+ throw new Error(`OpenAI API error ${res.status}: ${err}`);
397
+ }
398
+ const data = await res.json();
399
+ let content = '';
400
+ const toolCalls = [];
401
+ for (const item of (data.output || [])) {
402
+ if (item.type === 'message') {
403
+ for (const part of (item.content || [])) {
404
+ if (part.type === 'output_text')
405
+ content += part.text;
406
+ }
407
+ }
408
+ if (item.type === 'function_call') {
409
+ try {
410
+ toolCalls.push({ id: item.call_id || item.id || '', name: item.name || '', arguments: JSON.parse(item.arguments || '{}') });
411
+ }
412
+ catch { /* skip */ }
413
+ }
414
+ }
415
+ if (!content && data.output_text)
416
+ content = data.output_text;
417
+ if (toolCalls.length && config.toolExecutor) {
418
+ return handleOpenAIToolLoop(config, baseUrl, headers, body, content, toolCalls, data.usage?.input_tokens, data.usage?.output_tokens, undefined);
419
+ }
420
+ return {
421
+ content, model: data.model || config.model,
422
+ inputTokens: data.usage?.input_tokens, outputTokens: data.usage?.output_tokens,
423
+ toolCalls: toolCalls.length ? toolCalls : undefined,
424
+ };
425
+ }
426
+ }
427
+ /** Agentic tool-call loop for OpenAI Responses API */
428
+ async function handleOpenAIToolLoop(config, baseUrl, headers, origBody, partialContent, pending, inTok, outTok, onChunk) {
429
+ const maxRounds = config.maxToolRounds ?? 5;
430
+ const all = [...pending];
431
+ let content = partialContent;
432
+ let inputTokens = inTok;
433
+ let outputTokens = outTok;
434
+ for (let round = 0; round < maxRounds && pending.length; round++) {
435
+ const results = [];
436
+ for (const tc of pending) {
437
+ let r;
438
+ try {
439
+ r = await config.toolExecutor(tc.name, tc.arguments);
440
+ }
441
+ catch (e) {
442
+ r = `Error: ${e.message}`;
443
+ }
444
+ results.push({ type: 'function_call_output', call_id: tc.id, output: r });
445
+ }
446
+ const followUp = { ...origBody, input: results, stream: !!onChunk };
447
+ const res = await fetch(`${baseUrl}/v1/responses`, { method: 'POST', headers, body: JSON.stringify(followUp) });
448
+ if (!res.ok) {
449
+ const err = await res.text();
450
+ throw new Error(`OpenAI tool follow-up error ${res.status}: ${err}`);
451
+ }
452
+ pending = [];
453
+ if (onChunk) {
454
+ const reader = res.body?.getReader();
455
+ if (!reader)
456
+ throw new Error('No response body');
457
+ const dec = new TextDecoder();
458
+ let buf = '';
459
+ while (true) {
460
+ const { done, value } = await reader.read();
461
+ if (done)
462
+ break;
463
+ buf += dec.decode(value, { stream: true });
464
+ const lines = buf.split('\n');
465
+ buf = lines.pop() || '';
466
+ for (const ln of lines) {
467
+ if (!ln.startsWith('data: '))
468
+ continue;
469
+ const d = ln.slice(6).trim();
470
+ if (d === '[DONE]')
471
+ continue;
472
+ try {
473
+ const ev = JSON.parse(d);
474
+ if (ev.type === 'response.output_text.delta' && ev.delta) {
475
+ content += ev.delta;
476
+ onChunk(ev.delta);
477
+ }
478
+ if (ev.type === 'response.function_call_arguments.done') {
479
+ try {
480
+ const tc = { id: ev.call_id || '', name: ev.name || '', arguments: JSON.parse(ev.arguments || '{}') };
481
+ pending.push(tc);
482
+ all.push(tc);
483
+ }
484
+ catch { /* skip */ }
485
+ }
486
+ if (ev.type === 'response.completed' && ev.response?.usage) {
487
+ inputTokens = (inputTokens || 0) + (ev.response.usage.input_tokens || 0);
488
+ outputTokens = (outputTokens || 0) + (ev.response.usage.output_tokens || 0);
489
+ }
490
+ }
491
+ catch { /* skip */ }
492
+ }
493
+ }
494
+ }
495
+ else {
496
+ const data = await res.json();
497
+ for (const item of (data.output || [])) {
498
+ if (item.type === 'message') {
499
+ for (const p of (item.content || [])) {
500
+ if (p.type === 'output_text')
501
+ content += p.text;
502
+ }
503
+ }
504
+ if (item.type === 'function_call') {
505
+ try {
506
+ const tc = { id: item.call_id || item.id || '', name: item.name || '', arguments: JSON.parse(item.arguments || '{}') };
507
+ pending.push(tc);
508
+ all.push(tc);
509
+ }
510
+ catch { /* skip */ }
511
+ }
512
+ }
513
+ if (data.output_text && !content)
514
+ content = data.output_text;
515
+ if (data.usage) {
516
+ inputTokens = (inputTokens || 0) + (data.usage.input_tokens || 0);
517
+ outputTokens = (outputTokens || 0) + (data.usage.output_tokens || 0);
518
+ }
519
+ }
520
+ }
521
+ return { content, model: config.model, inputTokens, outputTokens, toolCalls: all.length ? all : undefined };
522
+ }
204
523
  // ─── OpenAI-compatible Chat Completions ──────────────────────────────
205
524
  async function callOpenAICompatible(config, systemPrompt, userMessage, onChunk) {
206
525
  const baseUrl = config.baseUrl || BASE_URLS[config.provider] || BASE_URLS.openai;
@@ -209,31 +528,39 @@ async function callOpenAICompatible(config, systemPrompt, userMessage, onChunk)
209
528
  'Content-Type': 'application/json',
210
529
  'Authorization': `Bearer ${config.apiKey}`,
211
530
  };
212
- // OpenRouter requires extra headers
213
531
  if (config.provider === 'openrouter') {
214
532
  headers['HTTP-Referer'] = 'https://guardlink.bugb.io';
215
533
  headers['X-Title'] = 'GuardLink CLI';
216
534
  }
535
+ const isDeepSeekReasoner = config.provider === 'deepseek' && config.model.includes('reasoner');
536
+ const body = {
537
+ model: config.model,
538
+ max_tokens: maxTokens,
539
+ messages: [
540
+ { role: 'system', content: systemPrompt },
541
+ { role: 'user', content: userMessage },
542
+ ],
543
+ };
544
+ if (config.responseFormat === 'json') {
545
+ body.response_format = { type: 'json_object' };
546
+ }
547
+ if (config.tools?.length) {
548
+ body.tools = config.tools.map(t => ({
549
+ type: 'function',
550
+ function: { name: t.name, description: t.description, parameters: t.parameters },
551
+ }));
552
+ }
217
553
  if (onChunk) {
218
- // Streaming
554
+ body.stream = true;
219
555
  const res = await fetch(`${baseUrl}/v1/chat/completions`, {
220
- method: 'POST',
221
- headers,
222
- body: JSON.stringify({
223
- model: config.model,
224
- max_tokens: maxTokens,
225
- stream: true,
226
- messages: [
227
- { role: 'system', content: systemPrompt },
228
- { role: 'user', content: userMessage },
229
- ],
230
- }),
556
+ method: 'POST', headers, body: JSON.stringify(body),
231
557
  });
232
558
  if (!res.ok) {
233
559
  const err = await res.text();
234
560
  throw new Error(`${config.provider} API error ${res.status}: ${err}`);
235
561
  }
236
562
  let content = '';
563
+ let reasoning = '';
237
564
  const reader = res.body?.getReader();
238
565
  if (!reader)
239
566
  throw new Error('No response body');
@@ -259,36 +586,31 @@ async function callOpenAICompatible(config, systemPrompt, userMessage, onChunk)
259
586
  content += delta;
260
587
  onChunk(delta);
261
588
  }
589
+ const reasoningDelta = event.choices?.[0]?.delta?.reasoning_content;
590
+ if (reasoningDelta)
591
+ reasoning += reasoningDelta;
262
592
  }
263
593
  catch { /* skip */ }
264
594
  }
265
595
  }
266
- return { content, model: config.model };
596
+ return { content, model: config.model, thinking: reasoning || undefined };
267
597
  }
268
598
  else {
269
- // Non-streaming
270
599
  const res = await fetch(`${baseUrl}/v1/chat/completions`, {
271
- method: 'POST',
272
- headers,
273
- body: JSON.stringify({
274
- model: config.model,
275
- max_tokens: maxTokens,
276
- messages: [
277
- { role: 'system', content: systemPrompt },
278
- { role: 'user', content: userMessage },
279
- ],
280
- }),
600
+ method: 'POST', headers, body: JSON.stringify(body),
281
601
  });
282
602
  if (!res.ok) {
283
603
  const err = await res.text();
284
604
  throw new Error(`${config.provider} API error ${res.status}: ${err}`);
285
605
  }
286
606
  const data = await res.json();
607
+ const choice = data.choices?.[0];
287
608
  return {
288
- content: data.choices?.[0]?.message?.content || '',
609
+ content: choice?.message?.content || '',
289
610
  model: data.model || config.model,
290
611
  inputTokens: data.usage?.prompt_tokens,
291
612
  outputTokens: data.usage?.completion_tokens,
613
+ thinking: isDeepSeekReasoner ? (choice?.message?.reasoning_content || undefined) : undefined,
292
614
  };
293
615
  }
294
616
  }