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 +32 -0
- cvelab/__main__.py +17 -0
- cvelab/ai.py +547 -0
- cvelab/autoflow.py +296 -0
- cvelab/cli.py +373 -0
- cvelab/closed.py +377 -0
- cvelab/closed_auto.py +256 -0
- cvelab/core.py +791 -0
- cvelab/deliverables.py +630 -0
- cvelab/doctor.py +308 -0
- cvelab/progress.py +84 -0
- cvelab/providers.py +263 -0
- cvelab/sourcegen.py +1168 -0
- cvelab/templates.py +349 -0
- cvelab/upload.py +293 -0
- cvelab/usage.py +189 -0
- cvelab-0.6.0.dist-info/METADATA +520 -0
- cvelab-0.6.0.dist-info/RECORD +23 -0
- cvelab-0.6.0.dist-info/WHEEL +5 -0
- cvelab-0.6.0.dist-info/entry_points.txt +2 -0
- cvelab-0.6.0.dist-info/licenses/LICENSE +202 -0
- cvelab-0.6.0.dist-info/licenses/NOTICE +14 -0
- cvelab-0.6.0.dist-info/top_level.txt +1 -0
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)
|