Skip to content

Commit 433ab08

Browse files
authored
fix: support OpenAI-compatible LLM endpoints (#103)
Allow explicit provider selection to override model-name inference so custom OpenAI-compatible models are not misrouted to Gemini.
1 parent e99d6fc commit 433ab08

12 files changed

Lines changed: 192 additions & 45 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
# SKILL_SCANNER_LLM_API_KEY=your_api_key
66
# SKILL_SCANNER_LLM_MODEL=claude-3-5-sonnet-20241022
77

8+
# OpenAI-compatible custom endpoint
9+
# SKILL_SCANNER_LLM_API_KEY=your_custom_provider_key
10+
# SKILL_SCANNER_LLM_PROVIDER=openai
11+
# SKILL_SCANNER_LLM_MODEL=Cloud-Gemini-3.1-Pro
12+
# SKILL_SCANNER_LLM_BASE_URL=https://your.internal.llm/v1
13+
814
# Azure OpenAI
915
# SKILL_SCANNER_LLM_API_KEY=your_azure_key
1016
# SKILL_SCANNER_LLM_MODEL=azure/gpt-4.1

docs/reference/cli-command-reference.md

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ Flags shared by `scan` and `scan-all`:
3535
| `--enable-meta` | off | Enable the meta (cross-correlation) analyzer |
3636
| `--fail-on-findings` | off | Exit non-zero if critical or high findings are reported; equivalent to `--fail-on-severity high` (CI gate) |
3737
| `--fail-on-severity LEVEL` | off | Exit non-zero if findings at or above LEVEL exist (critical, high, medium, low, info) |
38-
| `--lenient` | off | Tolerate malformed skills: coerce bad fields, fill defaults, and continue instead of failing. When `SKILL.md` is absent, falls back to scanning `.md` files in the directory |
39-
| `--skill-file FILENAME` | `SKILL.md` | Custom metadata filename to use instead of `SKILL.md` |
38+
| `--lenient` | off | Tolerate malformed YAML / missing fields: coerce bad fields, fill defaults, and continue instead of failing. Binary and non-UTF-8 files always fail. |
4039
| `--detailed` | off | Include full evidence in output |
4140
| `--compact` | off | Minimize output (JSON: no pretty-print) |
4241
| `--verbose` | off | Verbose logging |
@@ -50,7 +49,8 @@ Command: `python -m skill_scanner.cli.cli --help`
5049

5150
```text
5251
usage: cli.py [-h] [--version]
53-
{scan,scan-all,list-analyzers,validate-rules,generate-policy,configure-policy,interactive} ...
52+
{scan,scan-all,list-analyzers,validate-rules,generate-policy,configure-policy,interactive}
53+
...
5454
5555
Skill Scanner - Security scanner for agent skills packages
5656
@@ -104,10 +104,11 @@ usage: cli.py scan [-h] [--format {summary,json,markdown,table,sarif,html}]
104104
[--vt-upload-files] [--use-aidefense]
105105
[--aidefense-api-key AIDEFENSE_API_KEY]
106106
[--aidefense-api-url AIDEFENSE_API_URL]
107-
[--llm-provider {anthropic,openai}]
107+
[--llm-provider {anthropic,openai,openai-compatible}]
108108
[--llm-consensus-runs N] [--llm-max-tokens N]
109109
[--use-trigger] [--enable-meta] [--policy PRESET_OR_PATH]
110-
[--lenient] [--custom-rules PATH] [--taxonomy PATH]
110+
[--lenient] [--skill-file FILENAME] [--custom-rules PATH]
111+
[--rule-packs PACK [PACK ...]] [--taxonomy PATH]
111112
[--threat-mapping PATH]
112113
skill_directory
113114
@@ -121,7 +122,8 @@ options:
121122
multiple times to produce several reports in one run,
122123
e.g. --format markdown --format sarif. Use 'sarif' for
123124
GitHub Code Scanning, 'html' for interactive report.
124-
--output, -o OUTPUT Default output file path (overridden by --output-<fmt>
125+
--output OUTPUT, -o OUTPUT
126+
Default output file path (overridden by --output-<fmt>
125127
for a specific format)
126128
--output-json OUTPUT_JSON
127129
Write JSON report to this file
@@ -157,8 +159,9 @@ options:
157159
AI Defense API key (or set AI_DEFENSE_API_KEY)
158160
--aidefense-api-url AIDEFENSE_API_URL
159161
AI Defense API URL (optional, defaults to US region)
160-
--llm-provider {anthropic,openai}
161-
LLM provider
162+
--llm-provider {anthropic,openai,openai-compatible}
163+
LLM provider shortcut or explicit OpenAI-compatible
164+
override
162165
--llm-consensus-runs N
163166
Run LLM analysis N times and keep only findings with
164167
majority agreement (reduces false positives, increases
@@ -171,9 +174,19 @@ options:
171174
Scan policy: preset name (strict, balanced,
172175
permissive) or path to custom YAML
173176
--lenient Tolerate malformed skills: coerce bad fields, fill
174-
defaults, and continue instead of failing
177+
defaults, and continue instead of failing. When
178+
SKILL.md is absent, falls back to scanning .md files
179+
in the directory as instruction bodies (supports non-
180+
Codex/Cursor formats such as Claude Code commands).
181+
--skill-file FILENAME
182+
Custom metadata filename to use instead of SKILL.md
183+
(e.g. README.md)
175184
--custom-rules PATH Path to directory containing custom YARA rules (.yara
176185
files)
186+
--rule-packs PACK [PACK ...]
187+
Additional signature rule packs to enable (e.g.
188+
'atr'). Use '--rule-packs list' to show available
189+
packs.
177190
--taxonomy PATH Path to custom taxonomy JSON/YAML (overrides
178191
SKILL_SCANNER_TAXONOMY_PATH)
179192
--threat-mapping PATH
@@ -206,11 +219,12 @@ usage: cli.py scan-all [-h] [--recursive] [--check-overlap]
206219
[--use-aidefense]
207220
[--aidefense-api-key AIDEFENSE_API_KEY]
208221
[--aidefense-api-url AIDEFENSE_API_URL]
209-
[--llm-provider {anthropic,openai}]
222+
[--llm-provider {anthropic,openai,openai-compatible}]
210223
[--llm-consensus-runs N] [--llm-max-tokens N]
211224
[--use-trigger] [--enable-meta]
212225
[--policy PRESET_OR_PATH] [--lenient]
213-
[--custom-rules PATH] [--taxonomy PATH]
226+
[--skill-file FILENAME] [--custom-rules PATH]
227+
[--rule-packs PACK [PACK ...]] [--taxonomy PATH]
214228
[--threat-mapping PATH]
215229
skills_directory
216230
@@ -226,7 +240,8 @@ options:
226240
multiple times to produce several reports in one run,
227241
e.g. --format markdown --format sarif. Use 'sarif' for
228242
GitHub Code Scanning, 'html' for interactive report.
229-
--output, -o OUTPUT Default output file path (overridden by --output-<fmt>
243+
--output OUTPUT, -o OUTPUT
244+
Default output file path (overridden by --output-<fmt>
230245
for a specific format)
231246
--output-json OUTPUT_JSON
232247
Write JSON report to this file
@@ -262,8 +277,9 @@ options:
262277
AI Defense API key (or set AI_DEFENSE_API_KEY)
263278
--aidefense-api-url AIDEFENSE_API_URL
264279
AI Defense API URL (optional, defaults to US region)
265-
--llm-provider {anthropic,openai}
266-
LLM provider
280+
--llm-provider {anthropic,openai,openai-compatible}
281+
LLM provider shortcut or explicit OpenAI-compatible
282+
override
267283
--llm-consensus-runs N
268284
Run LLM analysis N times and keep only findings with
269285
majority agreement (reduces false positives, increases
@@ -276,9 +292,19 @@ options:
276292
Scan policy: preset name (strict, balanced,
277293
permissive) or path to custom YAML
278294
--lenient Tolerate malformed skills: coerce bad fields, fill
279-
defaults, and continue instead of failing
295+
defaults, and continue instead of failing. When
296+
SKILL.md is absent, falls back to scanning .md files
297+
in the directory as instruction bodies (supports non-
298+
Codex/Cursor formats such as Claude Code commands).
299+
--skill-file FILENAME
300+
Custom metadata filename to use instead of SKILL.md
301+
(e.g. README.md)
280302
--custom-rules PATH Path to directory containing custom YARA rules (.yara
281303
files)
304+
--rule-packs PACK [PACK ...]
305+
Additional signature rule packs to enable (e.g.
306+
'atr'). Use '--rule-packs list' to show available
307+
packs.
282308
--taxonomy PATH Path to custom taxonomy JSON/YAML (overrides
283309
SKILL_SCANNER_TAXONOMY_PATH)
284310
--threat-mapping PATH
@@ -320,7 +346,8 @@ usage: cli.py generate-policy [-h] [--output OUTPUT]
320346
321347
options:
322348
-h, --help show this help message and exit
323-
--output, -o OUTPUT Output file path
349+
--output OUTPUT, -o OUTPUT
350+
Output file path
324351
--preset {strict,balanced,permissive}
325352
Base preset
326353
```
@@ -338,9 +365,11 @@ Command: `python -m skill_scanner.cli.cli configure-policy --help`
338365
usage: cli.py configure-policy [-h] [--output OUTPUT] [--input INPUT]
339366
340367
options:
341-
-h, --help show this help message and exit
342-
--output, -o OUTPUT Output file path
343-
--input, -i INPUT Load existing policy YAML for editing
368+
-h, --help show this help message and exit
369+
--output OUTPUT, -o OUTPUT
370+
Output file path
371+
--input INPUT, -i INPUT
372+
Load existing policy YAML for editing
344373
```
345374

346375
</details>

docs/reference/configuration-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Primary settings for the LLM semantic analyzer.
2525
|---|---|---|
2626
| `SKILL_SCANNER_LLM_API_KEY` | Primary API key for LLM analyzer and meta fallback. **(required)** | `sk-ant-...` |
2727
| `SKILL_SCANNER_LLM_MODEL` | Primary model identifier for semantic analysis. | `anthropic/claude-sonnet-4-20250514` |
28+
| `SKILL_SCANNER_LLM_PROVIDER` | Optional provider override, including OpenAI-compatible custom endpoint routing. | `openai` |
2829
| `SKILL_SCANNER_LLM_BASE_URL` | Optional custom endpoint base URL for provider routing. | `https://api.openai.com/v1` |
2930
| `SKILL_SCANNER_LLM_API_VERSION` | Optional API version for providers that require one. | `2024-02-15-preview` |
3031
| `SKILL_SCANNER_LLM_FORCE_JSON_OBJECT` | Skip json_schema and start in plain JSON mode for incompatible proxies. | `true` |
@@ -120,6 +121,7 @@ Paths, allowlists, and other advanced settings.
120121
| `SKILL_SCANNER_LLM_BASE_URL` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
121122
| `SKILL_SCANNER_LLM_FORCE_JSON_OBJECT` | `.env.example` |
122123
| `SKILL_SCANNER_LLM_MODEL` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/config/config.py`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/behavioral_analyzer.py`, `skill_scanner/core/analyzers/llm_analyzer.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
124+
| `SKILL_SCANNER_LLM_PROVIDER` | `.env.example`, `skill_scanner/core/analyzer_factory.py`, `skill_scanner/core/analyzers/llm_provider_config.py` |
123125
| `SKILL_SCANNER_META_LLM_API_KEY` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
124126
| `SKILL_SCANNER_META_LLM_API_VERSION` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |
125127
| `SKILL_SCANNER_META_LLM_BASE_URL` | `.env.example`, `skill_scanner/cli/cli.py`, `skill_scanner/core/analyzers/meta_analyzer.py` |

docs/reference/dependencies-and-llm-providers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Set `SKILL_SCANNER_LLM_MODEL` using the provider prefix convention:
9090
|----------|--------------|-------|
9191
| Anthropic | `anthropic/claude-sonnet-4-20250514` | Default provider |
9292
| OpenAI | `openai/gpt-4o` | |
93+
| OpenAI-compatible custom endpoint | `Cloud-Gemini-3.1-Pro` with `SKILL_SCANNER_LLM_PROVIDER=openai` | Uses `SKILL_SCANNER_LLM_BASE_URL` |
9394
| AWS Bedrock | `bedrock/anthropic.claude-sonnet-4-20250514-v1:0` | Requires `[bedrock]` extra or API key |
9495
| Google Vertex AI | `vertex_ai/gemini-2.5-pro` | Requires `[vertex]` extra |
9596
| Google AI Studio | `gemini/gemini-2.5-flash` | Requires `[google]` extra |
@@ -102,6 +103,7 @@ Set `SKILL_SCANNER_LLM_MODEL` using the provider prefix convention:
102103
|----------|-------------|-------------------|
103104
| Anthropic | API key | `SKILL_SCANNER_LLM_API_KEY` |
104105
| OpenAI | API key | `SKILL_SCANNER_LLM_API_KEY` |
106+
| OpenAI-compatible custom endpoint | API key + endpoint | `SKILL_SCANNER_LLM_API_KEY`, `SKILL_SCANNER_LLM_PROVIDER=openai`, `SKILL_SCANNER_LLM_BASE_URL` |
105107
| AWS Bedrock (API key) | API key | `SKILL_SCANNER_LLM_API_KEY` |
106108
| AWS Bedrock (IAM) | AWS credentials | `AWS_REGION`, `AWS_PROFILE` (optional: `AWS_SESSION_TOKEN`) |
107109
| Google Vertex AI | Service account | `GOOGLE_APPLICATION_CREDENTIALS` |

docs/user-guide/installation-and-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ You only need to set these if you're using the corresponding features. Click a s
5151
<summary>Core LLM</summary>
5252
5353
- `SKILL_SCANNER_LLM_API_KEY`
54+
- `SKILL_SCANNER_LLM_PROVIDER`set to `openai` for OpenAI-compatible custom endpoints when the model name is not enough to infer routing
5455
- `SKILL_SCANNER_LLM_MODEL`
5556
- `SKILL_SCANNER_LLM_BASE_URL`
5657
- `SKILL_SCANNER_LLM_API_VERSION`

examples/llm_analyzer_example.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
Prerequisites:
2727
export SKILL_SCANNER_LLM_API_KEY="your_key"
2828
export SKILL_SCANNER_LLM_MODEL="claude-3-5-sonnet-20241022" # or gpt-4o
29+
# For OpenAI-compatible custom endpoints:
30+
# export SKILL_SCANNER_LLM_PROVIDER="openai"
31+
# export SKILL_SCANNER_LLM_BASE_URL="https://your.internal.llm/v1"
2932
pip install cisco-ai-skill-scanner[llm]
3033
3134
Usage:
@@ -40,7 +43,7 @@
4043
from skill_scanner.core.analyzers.static import StaticAnalyzer
4144

4245
try:
43-
from skill_scanner.core.analyzers.llm_analyzer import LLMAnalyzer, LLMProvider
46+
from skill_scanner.core.analyzers.llm_analyzer import LLMAnalyzer
4447

4548
LLM_AVAILABLE = True
4649
except ImportError:
@@ -69,12 +72,19 @@ def main():
6972
return 1
7073

7174
model = os.getenv("SKILL_SCANNER_LLM_MODEL", "claude-3-5-sonnet-20241022")
75+
provider = os.getenv("SKILL_SCANNER_LLM_PROVIDER")
76+
base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")
77+
api_version = os.getenv("SKILL_SCANNER_LLM_API_VERSION")
7278

7379
print(f"{'=' * 60}")
7480
print("LLM Analyzer Example")
7581
print(f"{'=' * 60}")
7682
print(f"Skill: {skill_path}")
7783
print(f"Model: {model}")
84+
if provider:
85+
print(f"Provider: {provider}")
86+
if base_url:
87+
print(f"Base URL: {base_url}")
7888
print()
7989

8090
# Scan with static analyzer only
@@ -88,7 +98,13 @@ def main():
8898

8999
# Scan with LLM analyzer
90100
print("Step 2: Scanning with LLM Analyzer...")
91-
llm_analyzer = LLMAnalyzer(model=model, api_key=api_key)
101+
llm_analyzer = LLMAnalyzer(
102+
model=model,
103+
api_key=api_key,
104+
provider=provider,
105+
base_url=base_url,
106+
api_version=api_version,
107+
)
92108
llm_scanner = SkillScanner(analyzers=[llm_analyzer])
93109
llm_result = llm_scanner.scan_skill(skill_path)
94110

scripts/generate_reference_docs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def _describe_env_var(var: str) -> str:
391391
descriptions = {
392392
"SKILL_SCANNER_LLM_API_KEY": "Primary API key for LLM analyzer and meta fallback.",
393393
"SKILL_SCANNER_LLM_MODEL": "Primary model identifier for semantic analysis.",
394+
"SKILL_SCANNER_LLM_PROVIDER": "Optional provider override, including OpenAI-compatible custom endpoint routing.",
394395
"SKILL_SCANNER_LLM_BASE_URL": "Optional custom endpoint base URL for provider routing.",
395396
"SKILL_SCANNER_LLM_API_VERSION": "Optional API version for providers that require one.",
396397
"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT": "Skip json_schema and start in plain JSON mode for incompatible proxies.",
@@ -425,6 +426,7 @@ def _describe_env_var(var: str) -> str:
425426
[
426427
"SKILL_SCANNER_LLM_API_KEY",
427428
"SKILL_SCANNER_LLM_MODEL",
429+
"SKILL_SCANNER_LLM_PROVIDER",
428430
"SKILL_SCANNER_LLM_BASE_URL",
429431
"SKILL_SCANNER_LLM_API_VERSION",
430432
"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT",
@@ -484,6 +486,7 @@ def _describe_env_var(var: str) -> str:
484486
_ENV_VAR_EXAMPLES: dict[str, str] = {
485487
"SKILL_SCANNER_LLM_API_KEY": "sk-ant-...",
486488
"SKILL_SCANNER_LLM_MODEL": "anthropic/claude-sonnet-4-20250514",
489+
"SKILL_SCANNER_LLM_PROVIDER": "openai",
487490
"SKILL_SCANNER_LLM_BASE_URL": "https://api.openai.com/v1",
488491
"SKILL_SCANNER_LLM_API_VERSION": "2024-02-15-preview",
489492
"SKILL_SCANNER_LLM_FORCE_JSON_OBJECT": "true",

skill_scanner/cli/cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,12 @@ def _add_common_scan_flags(parser: argparse.ArgumentParser) -> None:
799799
parser.add_argument("--use-aidefense", action="store_true", help="Enable AI Defense analyzer (requires API key)")
800800
parser.add_argument("--aidefense-api-key", help="AI Defense API key (or set AI_DEFENSE_API_KEY)")
801801
parser.add_argument("--aidefense-api-url", help="AI Defense API URL (optional, defaults to US region)")
802-
parser.add_argument("--llm-provider", choices=["anthropic", "openai"], default="anthropic", help="LLM provider")
802+
parser.add_argument(
803+
"--llm-provider",
804+
choices=["anthropic", "openai", "openai-compatible"],
805+
default=None,
806+
help="LLM provider shortcut or explicit OpenAI-compatible override",
807+
)
803808
parser.add_argument(
804809
"--llm-consensus-runs",
805810
type=int,

skill_scanner/core/analyzer_factory.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,22 +142,27 @@ def build_analyzers(
142142
try:
143143
from .analyzers.llm_analyzer import LLMAnalyzer
144144

145-
model = llm_model or os.getenv("SKILL_SCANNER_LLM_MODEL") or "claude-3-5-sonnet-20241022"
145+
env_model = os.getenv("SKILL_SCANNER_LLM_MODEL")
146+
model = llm_model or env_model
146147
key = llm_api_key or os.getenv("SKILL_SCANNER_LLM_API_KEY")
147148
base_url = llm_base_url or os.getenv("SKILL_SCANNER_LLM_BASE_URL")
148149
api_version = llm_api_version or os.getenv("SKILL_SCANNER_LLM_API_VERSION")
150+
provider = llm_provider or os.getenv("SKILL_SCANNER_LLM_PROVIDER")
149151
extra_kwargs: dict = {}
150152
effective_max_tokens = (
151153
llm_max_tokens if llm_max_tokens is not None else policy.llm_analysis.max_output_tokens
152154
)
153155
if effective_max_tokens is not None:
154156
extra_kwargs["max_tokens"] = effective_max_tokens
155-
if llm_provider and not llm_model and not os.getenv("SKILL_SCANNER_LLM_MODEL"):
156-
llm = LLMAnalyzer(provider=llm_provider, policy=policy, **extra_kwargs)
157-
else:
158-
llm = LLMAnalyzer(
159-
model=model, api_key=key, base_url=base_url, api_version=api_version, policy=policy, **extra_kwargs
160-
)
157+
llm = LLMAnalyzer(
158+
model=model,
159+
api_key=key,
160+
base_url=base_url,
161+
api_version=api_version,
162+
provider=provider,
163+
policy=policy,
164+
**extra_kwargs,
165+
)
161166
if llm_consensus_runs > 1:
162167
llm.consensus_runs = llm_consensus_runs
163168
analyzers.append(llm)

0 commit comments

Comments
 (0)