Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:

permissions:
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ WEB_ADDR ?= 127.0.0.1:8080
WEB_TOKEN ?=
BIN_DIR ?= bin

RE2_TAGS := re2_cgo re2_static
# The bundled static CRE2 archive only exists for linux/amd64 and
# windows/amd64. Other native builds use go-re2's embedded WASM runtime.
RE2_TAGS_DEFAULT :=
ifeq ($(OS),Windows_NT)
RE2_TAGS_DEFAULT := re2_cgo re2_static
else ifeq ($(shell uname -s),Linux)
ifeq ($(shell uname -m),x86_64)
RE2_TAGS_DEFAULT := re2_cgo re2_static
endif
endif
RE2_TAGS ?= $(RE2_TAGS_DEFAULT)

ifeq ($(OS),Windows_NT)
EXE := .exe
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \
export OPENAI_API_KEY="sk-..."

# CLI arguments
aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat
aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat
```

Config file `aiscan.yaml`:
Expand All @@ -201,7 +201,7 @@ llm:
max_tokens: 16384 # Maximum output per response
```

The request output limit is dynamically clamped to the remaining context: `min(max_tokens, context_window - current_context - 4096)`. Automatic compaction starts as the context approaches the configured window.
`context_window` is a literal token count: use `128000`, not `128K`. Values below 8192 are accepted, but the Web UI warns that they may be too small. The request output limit is dynamically clamped to the remaining context: `min(max_tokens, context_window - current_context - 4096)`. If no output space remains, AIScan returns a clear error instead of sending a one-token request. Automatic compaction starts as the context approaches the configured window.

---

Expand Down
4 changes: 2 additions & 2 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \
export OPENAI_API_KEY="sk-..."

# CLI 参数
aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat
aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat
```

配置文件 `aiscan.yaml`:
Expand All @@ -200,7 +200,7 @@ llm:
max_tokens: 16384 # 单次最大输出
```

实际请求的输出上限会按剩余上下文自动收紧:`min(max_tokens, context_window - 当前上下文 - 4096)`。上下文接近配置窗口时会自动压缩。
`context_window` 填写真实 Token 数,例如 `128000`,不要写 `128K`。小于 8192 的值可以保存,但 Web 页面会提示窗口可能过小。实际请求的输出上限会按剩余上下文自动收紧:`min(max_tokens, context_window - 当前上下文 - 4096)`;如果已没有输出空间,AIScan 会返回明确错误,而不是发送只允许输出 1 Token 的请求。上下文接近配置窗口时会自动压缩。

---

Expand Down
2 changes: 1 addition & 1 deletion core/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type ScanConfigOptions struct {
}

type LLMOptions struct {
Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot"`
Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot, zhipu"`
BaseURL string `long:"base-url" config:"base_url" description:"LLM API base URL (leave empty to use provider default)"`
APIKey string `long:"api-key" config:"api_key" description:"LLM API key (or env: OPENAI_API_KEY, ANTHROPIC_API_KEY, AISCAN_API_KEY)"`
Model string `long:"model" config:"model" description:"LLM model name"`
Expand Down
14 changes: 9 additions & 5 deletions docs/mechanisms.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,12 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事
### LLM 探活

- `TestLLM`: 发 `maxTokens=16` 的 "ping" completion 验证连通性
- `ListLLMModels`: 调用 provider 的 `GET /models` 返回 model picklist
- `ListLLMModels`: 调用 provider 的 `GET /models` 返回 model picklist;404 作为“不支持目录”正常降级为手动输入

### 安全

- `redactURLError`: 从 `*url.Error` 中剥离 query string(FOFA/Hunter API key 在 query 中)
- 空 APIKey 回退到 stored config 中的值(Settings UI 留空表示保持不变)
- 空 APIKey 按请求携带的 `profile_id` 回退到对应 stored config;缺省 ID 才使用 active profile

**文件**: `pkg/probe/conn.go`, `pkg/probe/llm.go`, `pkg/web/probe.go`, `pkg/web/handler.go`

Expand All @@ -128,15 +128,19 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事

### ListModels

两个 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。
两个协议 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。

### Provider presets

品牌 preset 在协议归一化前解析,为 OpenAI、Anthropic、DeepSeek、OpenRouter、Groq、Moonshot、Ollama 和 Zhipu GLM 提供默认 Base URL。`glm`、`bigmodel` 映射到 `zhipu`;显式 Base URL 不会被覆盖。Ollama preset 不要求 API Key。

### hint404 协议提示

chat endpoint 返回 404 时包裹 actionable 建议(如"设置 `llm.provider=anthropic`")。用 `%w` 保留原始 `*APIError` 链,不破坏 retry 分类。

### InferFromBaseURL

检测 `anthropic.com` 域名自动推断 provider,其他默认 `openai`。
这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。品牌默认地址由 preset 解析,不依赖域名猜测

**文件**: `pkg/agent/provider/anthropic.go`, `pkg/agent/provider/openai.go`, `pkg/agent/provider/http.go`, `pkg/agent/provider/provider.go`

Expand Down Expand Up @@ -186,7 +190,7 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。
- `params`: 插值变量(如 `{"filename": "note.txt", "path": "/tmp/..."}`)
- `fallback`: 英文文本,供非 i18n 消费者 / 日志 / 测试使用

持久化时 code+params 存入 `ChatMessage.Metadata` JSON,前端从中渲染本地化文本。
AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext["aiscan.web"]`。通用 reducer 会保留该扩展块,前端从中渲染本地化文本;因此实时流和重放使用同一参数来源

已定义的 code:

Expand Down
29 changes: 17 additions & 12 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ aiscan -c /path/to/aiscan.yaml scan -i 192.168.1.0/24 # 指定配置文件
```yaml
# LLM Provider
llm:
provider: "" # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic
provider: "" # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic, zhipu
base_url: "" # API base URL(留空使用 provider 默认值)
api_key: "" # API key(建议使用环境变量)
model: "" # 模型名称
context_window: 0 # 模型上下文窗口;0 表示按模型推断,未知模型默认 128000
context_window: 0 # 真实 Token 数;0 表示按模型推断,未知模型默认 128000
max_tokens: 0 # 单次最大输出;0 使用默认值 16384
proxy: "" # 访问 LLM API 的 HTTP proxy

Expand Down Expand Up @@ -161,7 +161,9 @@ misc:
| `--timeout <秒>` | 整体超时(默认 3600) |
| `-e, --eval` | 目标评估标准 — 独立 LLM 判断任务是否达成 |

`max_tokens` 并非无条件发送:AIScan 会预估消息和工具 schema 的 token 数,并按 `context_window - 当前上下文 - 4096` 自动收紧。上下文接近窗口时会按 Pi 的默认策略自动压缩;服务端返回上下文溢出时会压缩并自动重试一次。
`context_window` 使用真实整数,例如 128K 窗口填写 `128000`,不是 `128K`。所有正整数都可保存;Web 设置页会对小于 8192 的值显示非阻塞风险提示。

`max_tokens` 并非无条件发送:AIScan 会预估消息和工具 schema 的 token 数,并按 `context_window - 当前上下文 - 4096` 自动收紧。若安全预留后没有输出空间,请求会在发送前返回包含窗口、预估输入和预留量的明确错误。上下文接近窗口时会按 Pi 的默认策略自动压缩;服务端返回上下文溢出时会压缩并自动重试一次。

### Scanner 参数

Expand Down Expand Up @@ -202,19 +204,22 @@ misc:

| Provider | 默认 Base URL | 默认模型 | API Key 环境变量 |
| --- | --- | --- | --- |
| `openai` | `https://api.openai.com/v1` | `gpt-4o` | `OPENAI_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-chat` | `DEEPSEEK_API_KEY` |
| `anthropic` | `https://api.anthropic.com/v1` | — | `ANTHROPIC_API_KEY` |
| `openrouter` | `https://openrouter.ai/api/v1` | — | `OPENROUTER_API_KEY` |
| `groq` | `https://api.groq.com/openai/v1` | — | `GROQ_API_KEY` |
| `moonshot` | `https://api.moonshot.cn/v1` | — | `MOONSHOT_API_KEY` |
| `openai` | `https://api.openai.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
| `anthropic` | `https://api.anthropic.com/v1` | — | `AISCAN_API_KEY` / `ANTHROPIC_API_KEY` |
| `openrouter` | `https://openrouter.ai/api/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
| `groq` | `https://api.groq.com/openai/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
| `moonshot` | `https://api.moonshot.cn/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
| `ollama` | `http://localhost:11434/v1` | — | 不需要 |
| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |

aiscan 可以从 `--base-url` 自动推断 provider(如 URL 包含 `deepseek.com` 自动识别为 `deepseek`)
`glm` 和 `bigmodel` 是 `zhipu` 的别名。已知 Provider 在 `base_url` 留空时使用上表地址;显式填写的地址始终优先。只提供 `base_url` 而不提供 Provider 时,Anthropic 官方域名会选择 Anthropic 协议,其他地址默认按 OpenAI 兼容协议处理

### 多 Provider 配置

配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。
配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。`model` 必填,保存配置或激活 Profile 时都会拒绝空模型。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。

Web 设置页拉取模型列表时使用当前编辑 Profile 的已保存密钥。若端点不提供 `GET /models`(返回 404),页面会保留手动模型输入,不把它显示为连接故障。

Agent 只会重试当前 provider。重试耗尽后直接返回错误,不会自动切换到其他 profile,也不会把同一 turn 发给另一模型。

Expand All @@ -226,7 +231,7 @@ export OPENAI_API_KEY="sk-..."
aiscan agent -p "检查目标" -i http://target.example

# 指定 provider
aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key "sk-..." --model deepseek-chat
aiscan agent --provider deepseek --api-key "sk-..." --model deepseek-chat

# Ollama 本地模型
aiscan agent --provider ollama --model llama3 --base-url http://localhost:11434/v1
Expand Down
4 changes: 2 additions & 2 deletions pkg/agent/compact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ func TestEffectiveCompactionLimitsFitSmallContext(t *testing.T) {
}

func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) {
long := strings.Repeat("x", 240)
long := strings.Repeat("x", 9000)
llm := &scriptedProvider{responses: []*ChatCompletionResponse{
chatResponse(NewTextMessage("assistant", "history checkpoint")),
chatResponse(NewTextMessage("assistant", "turn-prefix checkpoint")),
Expand All @@ -230,7 +230,7 @@ func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) {
Tools: commands.NewRegistry(),
Model: "custom",
MaxTokens: 64,
ContextWindow: 180,
ContextWindow: 8192,
Compaction: CompactionSettings{
ReserveTokens: 40,
KeepRecentTokens: 20,
Expand Down
25 changes: 17 additions & 8 deletions pkg/agent/probe/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package probe

import (
"context"
"errors"
"strings"
"time"

Expand All @@ -15,11 +16,12 @@ import (
// to keep it unchanged). Model is only required for TestLLM; ListLLMModels
// ignores it.
type LLMProbeRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model,omitempty"`
Proxy string `json:"proxy"`
ProfileID string `json:"profile_id,omitempty"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model,omitempty"`
Proxy string `json:"proxy"`
}

// LLMTestResult reports whether a probe request reached the provider and
Expand All @@ -40,9 +42,10 @@ const llmProbeTimeout = 30 * time.Second
// LLMModelsResult reports the model IDs discovered at the endpoint. ok=false
// carries the reason (unsupported provider, auth failure, unreachable, …).
type LLMModelsResult struct {
OK bool `json:"ok"`
Models []string `json:"models,omitempty"`
Error string `json:"error,omitempty"`
OK bool `json:"ok"`
Supported bool `json:"supported"`
Models []string `json:"models,omitempty"`
Error string `json:"error,omitempty"`
}

// modelLister is the optional capability a provider implements when its
Expand Down Expand Up @@ -89,11 +92,17 @@ func ListLLMModels(ctx context.Context, req LLMProbeRequest, storedAPIKey string

models, err := lister.ListModels(probeCtx)
if err != nil {
var apiErr *agent.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
result.OK = true
return result, nil
}
result.Error = err.Error()
return result, nil
}

result.OK = true
result.Supported = true
result.Models = models
return result, nil
}
Expand Down
52 changes: 41 additions & 11 deletions pkg/agent/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ type ProviderConfig struct {
ContextWindow int `yaml:"context_window,omitempty" config:"context_window"`
}

type providerPreset struct {
Protocol string
BaseURL string
APIKeyRequired bool
}

var providerPresets = map[string]providerPreset{
"openai": {Protocol: "openai", BaseURL: "https://api.openai.com/v1", APIKeyRequired: true},
"anthropic": {Protocol: "anthropic", BaseURL: "https://api.anthropic.com/v1", APIKeyRequired: true},
"deepseek": {Protocol: "openai", BaseURL: "https://api.deepseek.com/v1", APIKeyRequired: true},
"openrouter": {Protocol: "openai", BaseURL: "https://openrouter.ai/api/v1", APIKeyRequired: true},
"groq": {Protocol: "openai", BaseURL: "https://api.groq.com/openai/v1", APIKeyRequired: true},
"moonshot": {Protocol: "openai", BaseURL: "https://api.moonshot.cn/v1", APIKeyRequired: true},
"ollama": {Protocol: "openai", BaseURL: "http://localhost:11434/v1"},
"zhipu": {Protocol: "openai", BaseURL: "https://open.bigmodel.cn/api/paas/v4", APIKeyRequired: true},
}

var providerAliases = map[string]string{
"bigmodel": "zhipu",
"glm": "zhipu",
}

func NormalizeProvider(name string) string {
if strings.EqualFold(name, "anthropic") {
return "anthropic"
Expand All @@ -58,25 +80,33 @@ func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) {
return nil, fmt.Errorf("context_window must be zero or positive")
}

if resolved.Provider == "" {
providerName := strings.ToLower(strings.TrimSpace(resolved.Provider))
if alias, ok := providerAliases[providerName]; ok {
providerName = alias
}

if providerName == "" {
if resolved.BaseURL != "" {
resolved.Provider = InferFromBaseURL(resolved.BaseURL)
providerName = InferFromBaseURL(resolved.BaseURL)
} else {
resolved.Provider = "openai"
providerName = "openai"
}
}
resolved.Provider = NormalizeProvider(resolved.Provider)

if resolved.BaseURL == "" {
switch resolved.Provider {
case "anthropic":
resolved.BaseURL = "https://api.anthropic.com/v1"
default:
resolved.BaseURL = "https://api.openai.com/v1"
preset, knownProvider := providerPresets[providerName]
if knownProvider {
if strings.TrimSpace(resolved.BaseURL) == "" {
resolved.BaseURL = preset.BaseURL
}
resolved.Provider = preset.Protocol
} else {
if strings.TrimSpace(resolved.BaseURL) == "" {
return nil, fmt.Errorf("unknown provider %q: set base_url for a custom OpenAI-compatible endpoint", providerName)
}
resolved.Provider = NormalizeProvider(providerName)
}

if resolved.APIKey == "" {
if strings.TrimSpace(resolved.APIKey) == "" && (!knownProvider || preset.APIKeyRequired) {
return nil, fmt.Errorf("no API key: set --api-key, llm.api_key, or AISCAN_API_KEY")
}

Expand Down
55 changes: 55 additions & 0 deletions pkg/agent/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,65 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

func TestResolveProviderPresets(t *testing.T) {
tests := []struct {
name string
provider string
apiKey string
wantProtocol string
wantBaseURL string
}{
{name: "openai", provider: "openai", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.openai.com/v1"},
{name: "anthropic", provider: "anthropic", apiKey: "key", wantProtocol: "anthropic", wantBaseURL: "https://api.anthropic.com/v1"},
{name: "deepseek", provider: "deepseek", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.deepseek.com/v1"},
{name: "openrouter", provider: "openrouter", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://openrouter.ai/api/v1"},
{name: "groq", provider: "groq", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.groq.com/openai/v1"},
{name: "moonshot", provider: "moonshot", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.moonshot.cn/v1"},
{name: "ollama", provider: "ollama", wantProtocol: "openai", wantBaseURL: "http://localhost:11434/v1"},
{name: "zhipu", provider: "zhipu", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"},
{name: "glm alias", provider: "glm", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"},
{name: "bigmodel alias", provider: "bigmodel", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := Resolve(&ProviderConfig{Provider: tt.provider, APIKey: tt.apiKey})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider != tt.wantProtocol || resolved.BaseURL != tt.wantBaseURL {
t.Fatalf("Resolve() = provider %q, base_url %q; want %q, %q", resolved.Provider, resolved.BaseURL, tt.wantProtocol, tt.wantBaseURL)
}
})
}
}

func TestResolvePreservesExplicitDeepSeekBaseURL(t *testing.T) {
resolved, err := Resolve(&ProviderConfig{
Provider: "deepseek",
BaseURL: "https://gateway.example/v1",
APIKey: "key",
})
if err != nil {
t.Fatal(err)
}
if resolved.BaseURL != "https://gateway.example/v1" || resolved.Provider != "openai" {
t.Fatalf("Resolve() = %+v", resolved)
}
}

func TestResolveUnknownProviderRequiresBaseURL(t *testing.T) {
_, err := Resolve(&ProviderConfig{Provider: "custom", APIKey: "key"})
if err == nil || !strings.Contains(err.Error(), "base_url") {
t.Fatalf("Resolve() error = %v, want base_url guidance", err)
}
}

func TestResolveUsesBaseURL(t *testing.T) {
cfg, err := Resolve(&ProviderConfig{
Provider: "ollama",
Expand Down
Loading