cvelab 0.6.0__py3-none-any.whl

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.
cvelab/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ # Copyright 2026 SyncBit Software SRL and the CVELab contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """CVELab: local-only CVE reproduction and PoC generation."""
16
+
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ try:
20
+ __version__ = version("cvelab")
21
+ except PackageNotFoundError:
22
+ try:
23
+ # cvelab.pyz has no dist-info for importlib.metadata to read, so the
24
+ # builder stamps the version in. Without this a downloaded archive
25
+ # reports 0.0.0+unknown in its User-Agent and in every upload.
26
+ # Generated by scripts/build_pyz.py, so it is absent from the
27
+ # source tree a type checker sees.
28
+ from ._version import __version__ # type: ignore[no-redef,import-not-found]
29
+ except ImportError: # a source tree that was never installed or built
30
+ __version__ = "0.0.0+unknown"
31
+
32
+ __all__ = ["__version__"]
cvelab/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ # Copyright 2026 SyncBit Software SRL and the CVELab contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from .cli import main
16
+
17
+ raise SystemExit(main())
cvelab/ai.py ADDED
@@ -0,0 +1,547 @@
1
+ # Copyright 2026 SyncBit Software SRL and the CVELab contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import urllib.error
21
+ import urllib.request
22
+ from urllib.parse import urlparse
23
+
24
+ from . import usage
25
+ from .providers import (
26
+ CHAT_COMPLETIONS,
27
+ MESSAGES,
28
+ PROVIDERS,
29
+ RESPONSES,
30
+ SELECTABLE,
31
+ auth_headers_for,
32
+ endpoint_for,
33
+ output_budget_for,
34
+ timeout_for,
35
+ )
36
+ from .providers import get as get_provider
37
+
38
+ PLAN_SCHEMA = {
39
+ "type": "object",
40
+ "additionalProperties": False,
41
+ "required": ["cwe", "confidence", "rationale", "source_hints", "manual_inputs"],
42
+ "properties": {
43
+ "cwe": {"type": "string"},
44
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1},
45
+ "rationale": {"type": "string"},
46
+ "source_hints": {"type": "array", "items": {"type": "string"}},
47
+ "manual_inputs": {"type": "array", "items": {"type": "string"}},
48
+ },
49
+ }
50
+
51
+
52
+ # Derived, never maintained by hand: see cvelab/providers.py.
53
+ SUPPORTED_PROVIDERS = set(SELECTABLE)
54
+ PROVIDER_KEY_VARIABLE = {p.name: p.key_variable for p in PROVIDERS.values()}
55
+
56
+
57
+ class AIHTTPError(RuntimeError):
58
+ def __init__(self, provider: str, status: int, detail: str):
59
+ super().__init__(f"{provider} API HTTP {status}: {detail[:4000]}")
60
+ self.status = status
61
+
62
+
63
+ def resolve_provider(provider: str | None = None) -> str:
64
+ selected = (provider or os.getenv("CVELAB_AI_PROVIDER") or "auto").lower()
65
+ if selected not in SUPPORTED_PROVIDERS:
66
+ raise ValueError(
67
+ f"Unsupported AI provider: {selected}. Choose "
68
+ + ", ".join(PROVIDERS)
69
+ + "."
70
+ )
71
+ if selected != "auto":
72
+ return selected
73
+ # A base URL means the user pointed us somewhere, which is what local is.
74
+ if os.getenv("CVELAB_BASE_URL"):
75
+ return "local"
76
+ # Otherwise the first provider whose key is present wins, with OpenAI
77
+ # last so an existing OpenAI setup keeps behaving as it did.
78
+ present = [
79
+ name for name, p in PROVIDERS.items()
80
+ if p.key_variable and os.getenv(p.key_variable) and name != "openai"
81
+ ]
82
+ if present and not os.getenv("OPENAI_API_KEY"):
83
+ return present[0]
84
+ return "openai"
85
+
86
+
87
+ def _provider_key(provider: str, api_key: str | None) -> str | None:
88
+ if api_key:
89
+ return api_key
90
+ return os.getenv(PROVIDER_KEY_VARIABLE.get(provider, "CVELAB_LOCAL_API_KEY"))
91
+
92
+
93
+ def ai_is_configured(
94
+ api_key: str | None,
95
+ model: str | None,
96
+ provider: str | None = None,
97
+ ) -> bool:
98
+ selected = resolve_provider(provider)
99
+ selected_model = model or os.getenv("CVELAB_MODEL")
100
+ required = get_provider(selected).requires_key
101
+ return bool(selected_model and (not required or _provider_key(selected, api_key)))
102
+
103
+
104
+ def provider_supports_web_search(provider: str | None = None) -> bool:
105
+ return get_provider(resolve_provider(provider)).web_search
106
+
107
+
108
+ def _local_base_url(base_url: str | None) -> str:
109
+ value = (base_url or os.getenv("CVELAB_BASE_URL") or "http://127.0.0.1:11434/v1").rstrip("/")
110
+ parsed = urlparse(value)
111
+ if (
112
+ parsed.scheme not in {"http", "https"}
113
+ or not parsed.hostname
114
+ or parsed.username
115
+ or parsed.password
116
+ or parsed.query
117
+ or parsed.fragment
118
+ ):
119
+ raise ValueError("Local AI base URL must be an HTTP(S) URL without credentials, query, or fragment")
120
+ return value
121
+
122
+
123
+ def _local_timeout() -> int:
124
+ raw = os.getenv("CVELAB_LOCAL_TIMEOUT", "1800")
125
+ try:
126
+ value = int(raw)
127
+ except ValueError as exc:
128
+ raise ValueError("CVELAB_LOCAL_TIMEOUT must be an integer number of seconds") from exc
129
+ if value < 1 or value > 86400:
130
+ raise ValueError("CVELAB_LOCAL_TIMEOUT must be between 1 and 86400 seconds")
131
+ return value
132
+
133
+
134
+ def _request_json(
135
+ provider: str,
136
+ url: str,
137
+ body: dict,
138
+ headers: dict[str, str],
139
+ *,
140
+ timeout_seconds: int = 180,
141
+ ) -> dict:
142
+ request = urllib.request.Request(
143
+ url,
144
+ data=json.dumps(body).encode("utf-8"),
145
+ headers={"Content-Type": "application/json", **headers},
146
+ method="POST",
147
+ )
148
+ try:
149
+ with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
150
+ return json.load(response)
151
+ except urllib.error.HTTPError as exc:
152
+ detail = exc.read().decode("utf-8", errors="replace")
153
+ raise AIHTTPError(provider, exc.code, detail) from exc
154
+ except urllib.error.URLError as exc:
155
+ raise RuntimeError(f"Could not connect to {provider} API at {url}: {exc.reason}") from exc
156
+
157
+
158
+ def _decode_json_text(text: str, provider: str) -> dict:
159
+ candidate = text.strip()
160
+ if candidate.startswith("```"):
161
+ lines = candidate.splitlines()
162
+ if lines and lines[0].startswith("```"):
163
+ lines = lines[1:]
164
+ if lines and lines[-1].strip() == "```":
165
+ lines = lines[:-1]
166
+ candidate = "\n".join(lines).strip()
167
+ try:
168
+ value = json.loads(candidate)
169
+ except json.JSONDecodeError as exc:
170
+ raise RuntimeError(f"{provider} response was not valid JSON: {exc}") from exc
171
+ if not isinstance(value, dict):
172
+ raise RuntimeError(f"{provider} structured response must be a JSON object")
173
+ return value
174
+
175
+
176
+ def _anthropic_compatible_schema(schema: dict) -> dict:
177
+ """Return Anthropic's supported JSON Schema subset without weakening local checks."""
178
+ unsupported = {
179
+ "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
180
+ "minLength", "maxLength", "maxItems", "uniqueItems", "contains", "minContains",
181
+ "maxContains", "minProperties", "maxProperties", "propertyNames",
182
+ "patternProperties", "dependentRequired", "dependentSchemas", "oneOf", "not",
183
+ "if", "then", "else", "unevaluatedProperties", "unevaluatedItems",
184
+ }
185
+ transformed = {}
186
+ removed = []
187
+ for key, value in schema.items():
188
+ if key in unsupported or (key == "minItems" and value not in {0, 1}):
189
+ removed.append(f"{key}={value!r}")
190
+ continue
191
+ if isinstance(value, dict):
192
+ transformed[key] = _anthropic_compatible_schema(value)
193
+ elif isinstance(value, list):
194
+ transformed[key] = [
195
+ _anthropic_compatible_schema(item) if isinstance(item, dict) else item
196
+ for item in value
197
+ ]
198
+ else:
199
+ transformed[key] = value
200
+ if removed:
201
+ note = "CVELab validates these constraints after generation: " + ", ".join(removed) + "."
202
+ description = transformed.get("description", "")
203
+ transformed["description"] = (description + " " + note).strip()
204
+ return transformed
205
+
206
+
207
+ def _validate_schema(value: object, schema: dict, path: str = "$") -> None:
208
+ alternatives = schema.get("anyOf")
209
+ if alternatives:
210
+ errors = []
211
+ for alternative in alternatives:
212
+ try:
213
+ _validate_schema(value, alternative, path)
214
+ return
215
+ except RuntimeError as exc:
216
+ errors.append(str(exc))
217
+ raise RuntimeError(f"Structured response does not match any schema at {path}: {'; '.join(errors)}")
218
+
219
+ declared = schema.get("type")
220
+ if declared:
221
+ allowed = declared if isinstance(declared, list) else [declared]
222
+ matches = {
223
+ "object": lambda item: isinstance(item, dict),
224
+ "array": lambda item: isinstance(item, list),
225
+ "string": lambda item: isinstance(item, str),
226
+ "number": lambda item: isinstance(item, (int, float)) and not isinstance(item, bool),
227
+ "integer": lambda item: isinstance(item, int) and not isinstance(item, bool),
228
+ "boolean": lambda item: isinstance(item, bool),
229
+ "null": lambda item: item is None,
230
+ }
231
+ if not any(matches[kind](value) for kind in allowed):
232
+ raise RuntimeError(f"Structured response has invalid type at {path}; expected {allowed}")
233
+ if "enum" in schema and value not in schema["enum"]:
234
+ raise RuntimeError(f"Structured response has invalid value at {path}")
235
+ if isinstance(value, dict):
236
+ required = schema.get("required", [])
237
+ missing = [key for key in required if key not in value]
238
+ if missing:
239
+ raise RuntimeError(f"Structured response is missing {', '.join(missing)} at {path}")
240
+ properties = schema.get("properties", {})
241
+ if schema.get("additionalProperties") is False:
242
+ unexpected = [key for key in value if key not in properties]
243
+ if unexpected:
244
+ raise RuntimeError(f"Structured response has unexpected fields at {path}: {', '.join(unexpected)}")
245
+ for key, item in value.items():
246
+ if key in properties:
247
+ _validate_schema(item, properties[key], f"{path}.{key}")
248
+ if isinstance(value, list):
249
+ if len(value) < schema.get("minItems", 0):
250
+ raise RuntimeError(f"Structured response array is too short at {path}")
251
+ if "maxItems" in schema and len(value) > schema["maxItems"]:
252
+ raise RuntimeError(f"Structured response array is too long at {path}")
253
+ if "items" in schema:
254
+ for index, item in enumerate(value):
255
+ _validate_schema(item, schema["items"], f"{path}[{index}]")
256
+ if isinstance(value, str):
257
+ if len(value) < schema.get("minLength", 0):
258
+ raise RuntimeError(f"Structured response string is too short at {path}")
259
+ if "pattern" in schema and not re.fullmatch(schema["pattern"], value):
260
+ raise RuntimeError(f"Structured response string does not match pattern at {path}")
261
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
262
+ if "minimum" in schema and value < schema["minimum"]:
263
+ raise RuntimeError(f"Structured response number is below minimum at {path}")
264
+ if "maximum" in schema and value > schema["maximum"]:
265
+ raise RuntimeError(f"Structured response number is above maximum at {path}")
266
+
267
+
268
+ def _openai_response(
269
+ api_key: str,
270
+ model: str,
271
+ instructions: str,
272
+ input_text: str,
273
+ schema: dict,
274
+ schema_name: str,
275
+ max_output_tokens: int,
276
+ tools: list[dict] | None,
277
+ include: list[str] | None,
278
+ ) -> dict:
279
+ body = {
280
+ "model": model,
281
+ "store": False,
282
+ "max_output_tokens": max_output_tokens,
283
+ "instructions": instructions,
284
+ "input": input_text,
285
+ "text": {
286
+ "format": {
287
+ "type": "json_schema",
288
+ "name": schema_name,
289
+ "strict": True,
290
+ "schema": schema,
291
+ }
292
+ },
293
+ }
294
+ if tools:
295
+ body["tools"] = tools
296
+ body["tool_choice"] = "auto"
297
+ if include:
298
+ body["include"] = include
299
+ payload = _request_json(
300
+ "OpenAI",
301
+ endpoint_for("openai"),
302
+ body,
303
+ auth_headers_for("openai", api_key),
304
+ )
305
+ usage.record("openai", model, payload)
306
+ for item in payload.get("output", []):
307
+ if item.get("type") != "message":
308
+ continue
309
+ for content in item.get("content", []):
310
+ if content.get("type") == "output_text":
311
+ return _decode_json_text(content["text"], "OpenAI")
312
+ raise RuntimeError("OpenAI response did not contain structured output")
313
+
314
+
315
+ def _anthropic_response(
316
+ api_key: str,
317
+ model: str,
318
+ instructions: str,
319
+ input_text: str,
320
+ schema: dict,
321
+ max_output_tokens: int,
322
+ tools: list[dict] | None,
323
+ ) -> dict:
324
+ compatible_schema = _anthropic_compatible_schema(schema)
325
+ effort = os.getenv("CVELAB_ANTHROPIC_EFFORT", "low").lower()
326
+ if effort not in {"low", "medium", "high", "xhigh", "max"}:
327
+ raise ValueError(
328
+ "CVELAB_ANTHROPIC_EFFORT must be low, medium, high, xhigh, or max"
329
+ )
330
+ body = {
331
+ "model": model,
332
+ "max_tokens": max_output_tokens,
333
+ "system": instructions,
334
+ "messages": [{"role": "user", "content": input_text}],
335
+ "output_config": {
336
+ "effort": effort,
337
+ "format": {"type": "json_schema", "schema": compatible_schema},
338
+ },
339
+ }
340
+ if tools:
341
+ body["tools"] = [
342
+ {"type": "web_search_20250305", "name": "web_search", "max_uses": 5}
343
+ for tool in tools
344
+ if tool.get("type") == "web_search"
345
+ ]
346
+ payload = _request_json(
347
+ "Anthropic",
348
+ endpoint_for("anthropic"),
349
+ body,
350
+ auth_headers_for("anthropic", api_key),
351
+ )
352
+ usage.record("anthropic", model, payload)
353
+ stop_reason = payload.get("stop_reason")
354
+ token_usage = payload.get("usage", {})
355
+ content = payload.get("content", [])
356
+ content_types = [block.get("type", "unknown") for block in content]
357
+ if stop_reason == "max_tokens":
358
+ raise RuntimeError(
359
+ "Anthropic reached max_tokens before producing structured output "
360
+ f"(output_tokens={token_usage.get('output_tokens', 'unknown')}, "
361
+ f"content_types={content_types})"
362
+ )
363
+ if stop_reason == "refusal" or any(block.get("type") == "refusal" for block in content):
364
+ refusal = next((block for block in content if block.get("type") == "refusal"), {})
365
+ details = payload.get("stop_details") or refusal
366
+ raise RuntimeError(f"Anthropic refused structured generation: {details}")
367
+ for block in content:
368
+ if block.get("type") == "text":
369
+ return _decode_json_text(block.get("text", ""), "Anthropic")
370
+ raise RuntimeError(
371
+ "Anthropic response did not contain structured output "
372
+ f"(stop_reason={stop_reason!r}, output_tokens={token_usage.get('output_tokens', 'unknown')}, "
373
+ f"content_types={content_types})"
374
+ )
375
+
376
+
377
+ def _chat_completions_response(
378
+ provider_name: str,
379
+ api_key: str | None,
380
+ base_url: str | None,
381
+ model: str,
382
+ instructions: str,
383
+ input_text: str,
384
+ schema: dict,
385
+ schema_name: str,
386
+ max_output_tokens: int,
387
+ ) -> dict:
388
+ """The OpenAI chat/completions dialect, for every provider that speaks it.
389
+
390
+ DeepSeek and a local server differ only in things the registry declares:
391
+ the endpoint, whether a key is needed, how long to wait, and whether
392
+ response_format json_schema is accepted. Where it is not, the schema
393
+ travels in the instructions and the structure is validated after the
394
+ response arrives, as it is for every provider.
395
+ """
396
+ provider = get_provider(provider_name)
397
+ if provider_name == "local":
398
+ # A local URL is whatever the user typed, so it is checked for
399
+ # credentials and stray components before it is used.
400
+ base = _local_base_url(base_url)
401
+ url = base if base.endswith("/chat/completions") else base + "/chat/completions"
402
+ else:
403
+ url = endpoint_for(provider_name, base_url)
404
+ headers = auth_headers_for(provider_name, api_key)
405
+ timeout = _local_timeout() if provider_name == "local" else timeout_for(provider_name)
406
+
407
+ schema_json = json.dumps(schema, ensure_ascii=True)
408
+ system = instructions
409
+ if not provider.json_schema:
410
+ system += (
411
+ " Return exactly one JSON object matching this schema, with no "
412
+ "prose and no code fence: " + schema_json
413
+ )
414
+ body = {
415
+ "model": model,
416
+ "messages": [
417
+ {"role": "system", "content": system},
418
+ {"role": "user", "content": input_text},
419
+ ],
420
+ "temperature": 0,
421
+ "max_tokens": max_output_tokens,
422
+ "response_format": (
423
+ {"type": "json_schema",
424
+ "json_schema": {"name": schema_name, "strict": True, "schema": schema}}
425
+ if provider.json_schema
426
+ else {"type": "json_object"}
427
+ ),
428
+ }
429
+ try:
430
+ payload = _request_json(provider.label, url, body, headers, timeout_seconds=timeout)
431
+ except AIHTTPError as exc:
432
+ if not provider.json_schema or exc.status not in {400, 422}:
433
+ raise
434
+ # The server refused the schema form; say it in words instead.
435
+ body["response_format"] = {"type": "json_object"}
436
+ body["messages"][0]["content"] += (
437
+ " Return exactly one JSON object matching this schema: " + schema_json
438
+ )
439
+ payload = _request_json(provider.label, url, body, headers, timeout_seconds=timeout)
440
+
441
+ usage.record(provider_name, model, payload)
442
+ choices = payload.get("choices", [])
443
+ if not choices:
444
+ raise RuntimeError(f"{provider.label} response did not contain a completion choice")
445
+ content = (choices[0].get("message", {}) or {}).get("content", "")
446
+ if isinstance(content, list):
447
+ content = "".join(item.get("text", "") for item in content if isinstance(item, dict))
448
+ if not str(content).strip():
449
+ finish = choices[0].get("finish_reason")
450
+ raise RuntimeError(
451
+ f"{provider.label} returned no content (finish_reason={finish!r}); "
452
+ "a truncated response usually means max_tokens was reached"
453
+ )
454
+ return _decode_json_text(str(content), provider.label)
455
+
456
+
457
+ def structured_response(
458
+ *,
459
+ api_key: str | None,
460
+ model: str | None,
461
+ instructions: str,
462
+ input_text: str,
463
+ schema: dict,
464
+ schema_name: str,
465
+ max_output_tokens: int,
466
+ tools: list[dict] | None = None,
467
+ include: list[str] | None = None,
468
+ provider: str | None = None,
469
+ base_url: str | None = None,
470
+ ) -> dict:
471
+ selected = resolve_provider(provider)
472
+ api_key = _provider_key(selected, api_key)
473
+ model = model or os.getenv("CVELAB_MODEL")
474
+ if get_provider(selected).requires_key and not api_key:
475
+ variable = PROVIDER_KEY_VARIABLE[selected]
476
+ raise RuntimeError(f"{variable} is not set; use --key or the environment variable")
477
+ if not model:
478
+ raise RuntimeError("CVELAB_MODEL is not set; use --model or the environment variable")
479
+ instructions += (
480
+ " Write all generated prose, documentation, comments, and user-facing messages in English. "
481
+ "Preserve source quotations, identifiers, and raw evidence verbatim."
482
+ )
483
+ provider = get_provider(selected)
484
+ # Asked for once here rather than at each of the six call sites that
485
+ # name a budget: what a model needs is a property of the model.
486
+ max_output_tokens = output_budget_for(selected, max_output_tokens)
487
+ # Dispatch on the dialect, not the name: a provider that speaks one we
488
+ # already handle needs no new request code.
489
+ if tools and not provider.web_search:
490
+ instructions += (
491
+ " No web-search tool is available from this endpoint. Use only the supplied input and "
492
+ "return insufficient-data status rather than inventing provenance."
493
+ )
494
+ if provider.dialect == RESPONSES:
495
+ result = _openai_response(
496
+ api_key or "", model, instructions, input_text, schema, schema_name,
497
+ max_output_tokens, tools, include,
498
+ )
499
+ elif provider.dialect == MESSAGES:
500
+ result = _anthropic_response(
501
+ api_key or "", model, instructions, input_text, schema,
502
+ max_output_tokens, tools,
503
+ )
504
+ elif provider.dialect == CHAT_COMPLETIONS:
505
+ result = _chat_completions_response(
506
+ selected, api_key, base_url, model, instructions, input_text,
507
+ schema, schema_name, max_output_tokens,
508
+ )
509
+ else:
510
+ raise RuntimeError(f"No handler for dialect {provider.dialect!r}")
511
+ _validate_schema(result, schema)
512
+ return result
513
+
514
+
515
+ def plan_with_ai(
516
+ dossier: dict,
517
+ api_key: str | None = None,
518
+ model: str | None = None,
519
+ provider: str | None = None,
520
+ base_url: str | None = None,
521
+ ) -> dict:
522
+ return structured_response(
523
+ api_key=api_key,
524
+ model=model,
525
+ max_output_tokens=1800,
526
+ schema=PLAN_SCHEMA,
527
+ schema_name="cvelab_plan",
528
+ instructions=(
529
+ "You classify CVE metadata for an authorized local defensive research lab. "
530
+ "Return classification and source hints only; do not design or expand a reproduction procedure. "
531
+ "Prefer the explicit CWE in the dossier."
532
+ ),
533
+ input_text=json.dumps(dossier, ensure_ascii=True),
534
+ provider=provider,
535
+ base_url=base_url,
536
+ )
537
+
538
+
539
+ def plan_with_openai(
540
+ dossier: dict,
541
+ api_key: str | None = None,
542
+ model: str | None = None,
543
+ provider: str | None = None,
544
+ base_url: str | None = None,
545
+ ) -> dict:
546
+ """Backward-compatible alias for callers using the original public helper."""
547
+ return plan_with_ai(dossier, api_key, model, provider, base_url)