ship-safe 9.1.1 → 9.2.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.
@@ -27,6 +27,8 @@ class BaseLLMProvider {
27
27
  this.apiKey = apiKey;
28
28
  this.model = options.model || null;
29
29
  this.baseUrl = options.baseUrl || null;
30
+ this.think = options.think || false;
31
+ this.thinkLevel = options.thinkLevel || 'high';
30
32
  }
31
33
 
32
34
  /**
@@ -36,6 +38,16 @@ class BaseLLMProvider {
36
38
  throw new Error(`${this.name}.complete() not implemented`);
37
39
  }
38
40
 
41
+ /**
42
+ * Stream a completion as an async iterable of text chunks.
43
+ * Default implementation falls back to complete() and yields the whole
44
+ * response at once — providers that support real streaming should override.
45
+ */
46
+ async *stream(systemPrompt, userPrompt, options = {}) {
47
+ const text = await this.complete(systemPrompt, userPrompt, options);
48
+ if (text) yield text;
49
+ }
50
+
39
51
  /**
40
52
  * Classify security findings using the LLM.
41
53
  */
@@ -44,7 +56,7 @@ class BaseLLMProvider {
44
56
  const response = await this.complete(
45
57
  'You are a security expert. Respond with JSON only, no markdown.',
46
58
  prompt,
47
- { maxTokens: 4096 }
59
+ { maxTokens: 4096, think: this.think, thinkLevel: this.thinkLevel }
48
60
  );
49
61
  return this.parseJSON(response);
50
62
  }
@@ -172,25 +184,32 @@ class AnthropicProvider extends BaseLLMProvider {
172
184
  class OpenAIProvider extends BaseLLMProvider {
173
185
  constructor(apiKey, options = {}) {
174
186
  super('OpenAI', apiKey, options);
175
- this.model = options.model || 'gpt-4o-mini';
187
+ this.model = options.model || 'gpt-5.5';
176
188
  this.baseUrl = options.baseUrl || 'https://api.openai.com/v1/chat/completions';
177
189
  }
178
190
 
179
191
  async complete(systemPrompt, userPrompt, options = {}) {
192
+ const body = {
193
+ model: this.model,
194
+ max_tokens: options.maxTokens || 2048,
195
+ messages: [
196
+ { role: 'system', content: systemPrompt },
197
+ { role: 'user', content: userPrompt },
198
+ ],
199
+ };
200
+
201
+ if (options.think) {
202
+ body.reasoning_effort = options.thinkLevel || 'high';
203
+ body.max_tokens = options.maxTokens || 16384;
204
+ }
205
+
180
206
  const response = await fetch(this.baseUrl, {
181
207
  method: 'POST',
182
208
  headers: {
183
209
  'Authorization': `Bearer ${this.apiKey}`,
184
210
  'Content-Type': 'application/json',
185
211
  },
186
- body: JSON.stringify({
187
- model: this.model,
188
- max_tokens: options.maxTokens || 2048,
189
- messages: [
190
- { role: 'system', content: systemPrompt },
191
- { role: 'user', content: userPrompt },
192
- ],
193
- }),
212
+ body: JSON.stringify(body),
194
213
  });
195
214
 
196
215
  if (!response.ok) {
@@ -200,6 +219,71 @@ class OpenAIProvider extends BaseLLMProvider {
200
219
  const data = await response.json();
201
220
  return data.choices?.[0]?.message?.content || '';
202
221
  }
222
+
223
+ /**
224
+ * Streaming variant for the OpenAI Chat Completions SSE protocol.
225
+ * Yields content tokens as they arrive. Inherited by every
226
+ * OpenAI-compatible provider (DeepSeek, Kimi, xAI, OpenRouter).
227
+ */
228
+ async *stream(systemPrompt, userPrompt, options = {}) {
229
+ const body = {
230
+ model: this.model,
231
+ max_tokens: options.maxTokens || 2048,
232
+ messages: [
233
+ { role: 'system', content: systemPrompt },
234
+ { role: 'user', content: userPrompt },
235
+ ],
236
+ stream: true,
237
+ };
238
+ if (options.think) {
239
+ body.reasoning_effort = options.thinkLevel || 'high';
240
+ body.max_tokens = options.maxTokens || 16384;
241
+ }
242
+
243
+ const response = await fetch(this.baseUrl, {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Authorization': `Bearer ${this.apiKey}`,
247
+ 'Content-Type': 'application/json',
248
+ 'Accept': 'text/event-stream',
249
+ },
250
+ body: JSON.stringify(body),
251
+ });
252
+
253
+ if (!response.ok) {
254
+ const errBody = await response.text().catch(() => '');
255
+ throw new Error(`${this.name} API error: HTTP ${response.status} ${errBody.slice(0, 200)}`);
256
+ }
257
+
258
+ const reader = response.body.getReader();
259
+ const decoder = new TextDecoder('utf-8');
260
+ let buffer = '';
261
+
262
+ while (true) {
263
+ const { value, done } = await reader.read();
264
+ if (done) break;
265
+ buffer += decoder.decode(value, { stream: true });
266
+
267
+ // SSE events are separated by blank lines; parse line-by-line and only
268
+ // act on `data: ` payloads. Everything else (event:, id:, comments) ignored.
269
+ const lines = buffer.split('\n');
270
+ buffer = lines.pop() ?? ''; // keep trailing partial line for next chunk
271
+
272
+ for (const raw of lines) {
273
+ const line = raw.trim();
274
+ if (!line.startsWith('data:')) continue;
275
+ const payload = line.slice(5).trim();
276
+ if (!payload || payload === '[DONE]') continue;
277
+ try {
278
+ const evt = JSON.parse(payload);
279
+ const delta = evt.choices?.[0]?.delta;
280
+ // Token text (delta.content) — yield as-is. Some providers also send
281
+ // tool_calls; ignored here since the REPL doesn't use tools yet.
282
+ if (delta?.content) yield delta.content;
283
+ } catch { /* malformed chunk — skip */ }
284
+ }
285
+ }
286
+ }
203
287
  }
204
288
 
205
289
  // =============================================================================
@@ -356,11 +440,17 @@ class GemmaProvider extends OllamaProvider {
356
440
 
357
441
  // Well-known OpenAI-compatible base URLs and their default models.
358
442
  const OPENAI_COMPATIBLE_PRESETS = {
443
+ 'gpt-5.5': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.5', envKey: 'OPENAI_API_KEY' },
444
+ 'gpt-5.5-pro': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.5-pro', envKey: 'OPENAI_API_KEY' },
445
+ 'gpt-5.4': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4', envKey: 'OPENAI_API_KEY' },
446
+ 'gpt-5.4-mini': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4-mini', envKey: 'OPENAI_API_KEY' },
447
+ 'gpt-5.4-nano': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4-nano', envKey: 'OPENAI_API_KEY' },
359
448
  groq: { baseUrl: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile', envKey: 'GROQ_API_KEY' },
360
449
  together: { baseUrl: 'https://api.together.xyz/v1/chat/completions', model: 'meta-llama/Llama-3-70b-chat-hf', envKey: 'TOGETHER_API_KEY' },
361
450
  mistral: { baseUrl: 'https://api.mistral.ai/v1/chat/completions', model: 'mistral-large-latest', envKey: 'MISTRAL_API_KEY' },
362
451
  cohere: { baseUrl: 'https://api.cohere.com/compatibility/v1/chat/completions', model: 'command-r-plus', envKey: 'COHERE_API_KEY' },
363
- deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', envKey: 'DEEPSEEK_API_KEY' },
452
+ deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-pro', envKey: 'DEEPSEEK_API_KEY' },
453
+ 'deepseek-flash': { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', envKey: 'DEEPSEEK_API_KEY' },
364
454
  perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },
365
455
  lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },
366
456
  xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },
@@ -380,7 +470,44 @@ class OpenAICompatibleProvider extends OpenAIProvider {
380
470
 
381
471
  /** Models known to support OpenAI function calling reliably */
382
472
  get supportsStructuredOutput() {
383
- return /kimi|moonshot|gpt-4|grok|deepseek|mistral-large/i.test(this.model || '');
473
+ return /kimi|moonshot|gpt-4|gpt-5|grok|deepseek|mistral-large/i.test(this.model || '');
474
+ }
475
+
476
+ async complete(systemPrompt, userPrompt, options = {}) {
477
+ const body = {
478
+ model: options.model || this.model,
479
+ max_tokens: options.maxTokens || 2048,
480
+ messages: [
481
+ { role: 'system', content: systemPrompt },
482
+ { role: 'user', content: userPrompt },
483
+ ],
484
+ };
485
+ if (options.jsonMode) body.response_format = { type: 'json_object' };
486
+ if (options.think) {
487
+ body.reasoning_effort = options.thinkLevel || 'high';
488
+ body.max_tokens = options.maxTokens || 16384;
489
+ }
490
+
491
+ const response = await fetch(this.baseUrl, {
492
+ method: 'POST',
493
+ headers: {
494
+ 'Authorization': `Bearer ${this.apiKey}`,
495
+ 'Content-Type': 'application/json',
496
+ },
497
+ body: JSON.stringify(body),
498
+ });
499
+
500
+ if (!response.ok) {
501
+ const errBody = await response.text().catch(() => '');
502
+ throw new Error(`${this.name} API error: HTTP ${response.status} ${errBody.slice(0, 200)}`);
503
+ }
504
+
505
+ const data = await response.json();
506
+ const msg = data.choices?.[0]?.message;
507
+ // Kimi K2.6 thinking mode: actual answer in `content`; `reasoning_content` is internal thinking only
508
+ // With jsonMode, rely only on content (json_object format guarantees it); otherwise fall back to reasoning
509
+ if (options.jsonMode) return msg?.content || '';
510
+ return msg?.content || msg?.reasoning_content || '';
384
511
  }
385
512
 
386
513
  /**
@@ -409,7 +536,7 @@ class OpenAICompatibleProvider extends OpenAIProvider {
409
536
  parameters: inputSchema,
410
537
  },
411
538
  }],
412
- tool_choice: { type: 'function', function: { name: toolName } },
539
+ tool_choice: 'required',
413
540
  }),
414
541
  });
415
542
 
@@ -478,8 +605,10 @@ export function createProvider(provider, apiKey, options = {}) {
478
605
  name.charAt(0).toUpperCase() + name.slice(1),
479
606
  apiKey,
480
607
  {
481
- baseUrl: options.baseUrl || preset.baseUrl,
482
- model: options.model || preset.model || 'default',
608
+ baseUrl: options.baseUrl || preset.baseUrl,
609
+ model: options.model || preset.model || 'default',
610
+ think: options.think,
611
+ thinkLevel: options.thinkLevel,
483
612
  }
484
613
  );
485
614
  }
@@ -492,7 +621,7 @@ export function createProvider(provider, apiKey, options = {}) {
492
621
  throw new Error(
493
622
  `Unknown LLM provider: "${provider}".\n` +
494
623
  `Built-in: anthropic, openai, google, ollama\n` +
495
- `Presets: groq, together, mistral, cohere, deepseek, perplexity, lmstudio, xai, kimi\n` +
624
+ `Presets: gpt-5.5, gpt-5.5-pro, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano, groq, together, mistral, cohere, deepseek, deepseek-flash, perplexity, lmstudio, xai, kimi\n` +
496
625
  `Custom: pass any name with --base-url <url>`
497
626
  );
498
627
  }
@@ -508,8 +637,10 @@ export function autoDetectProvider(rootPath, options = {}) {
508
637
  if (options.provider) {
509
638
  const apiKey = resolveApiKey(options.provider, rootPath);
510
639
  return createProvider(options.provider, apiKey, {
511
- model: options.model,
512
- baseUrl: options.baseUrl,
640
+ model: options.model,
641
+ baseUrl: options.baseUrl,
642
+ think: options.think,
643
+ thinkLevel: options.thinkLevel,
513
644
  });
514
645
  }
515
646
 
@@ -228,3 +228,24 @@ export function progress(text) {
228
228
  export function clearLine() {
229
229
  process.stdout.write('\r' + ' '.repeat(80) + '\r');
230
230
  }
231
+
232
+ /**
233
+ * Print the Ship Safe ASCII banner.
234
+ * Call at the top of any command that should show branding.
235
+ */
236
+ export function printBanner(version) {
237
+ console.log();
238
+ console.log(chalk.cyan('███████╗██╗ ██╗██╗██████╗ ███████╗ █████╗ ███████╗███████╗'));
239
+ console.log(chalk.cyan('██╔════╝██║ ██║██║██╔══██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝'));
240
+ console.log(chalk.cyan('███████╗███████║██║██████╔╝ ███████╗███████║█████╗ █████╗ '));
241
+ console.log(chalk.cyan('╚════██║██╔══██║██║██╔═══╝ ╚════██║██╔══██║██╔══╝ ██╔══╝ '));
242
+ console.log(chalk.cyan('███████║██║ ██║██║██║ ███████║██║ ██║██║ ███████╗'));
243
+ console.log(chalk.cyan('╚══════╝╚═╝ ╚═╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝'));
244
+ console.log();
245
+ if (version) {
246
+ console.log(chalk.gray(` v${version} · 23 agents · 80+ attack classes · shipsafecli.com`));
247
+ } else {
248
+ console.log(chalk.gray(' 23 agents · 80+ attack classes · shipsafecli.com'));
249
+ }
250
+ console.log();
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "9.1.1",
3
+ "version": "9.2.0",
4
4
  "description": "AI-powered multi-agent security platform. 23 agents scan 80+ attack classes including AI integration supply chain (Vercel-class attacks), Hermes Agent deployments (ASI-01–ASI-10), tool registry poisoning, function-call injection, skill permission drift, and agent attestation. Ship Safe × Hermes Agent.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {