module LLM
Defined in:
llm/acp/client.crllm/acp/targets.cr
llm/adapter.cr
llm/cache.cr
llm/general/client.cr
llm/http_transport.cr
llm/ollama/ollama.cr
llm/prompt.cr
llm/response_cleanup.cr
Constant Summary
-
ACP_DEFAULT_MAX_TOKENS =
128000 -
Bundling budget for ACP agent providers ("acp:gemini", "acp:codex", "acp:claude", …). These proxy to large-context frontier models whose exact id we don't control, so they aren't keys in MODEL_TOKEN_LIMITS. Falling through to the generic 4000- token global default would shard a project into many tiny bundles and break the cross-file context the bundle analyzer relies on to resolve route prefixes (a false-negative source). 128k is a safe lower bound across the current ACP agents.
-
AGENT_PROMPT =
"You are OWASP Noir's endpoint discovery agent.\nYour goal is to discover all API endpoints from source code with high confidence.\n\nAllowed actions:\n- list_directory(path: string = \".\", max_depth: integer = 3)\n- read_file(path: string)\n- grep(pattern: string, path: string = \".\", file_pattern: string = \"*\")\n- semantic_search(query: string)\n- finalize(endpoints: array, summary: string, confidence: integer 0..100)\n\nRules:\n- First inspect project structure with list_directory or grep.\n- Never assume endpoint existence from filenames only.\n- Read source files before finalizing.\n- Resolve the FULL request path: follow route prefixes, router/blueprint groups, controller base paths, and mount points back to their declaration before recording a URL.\n- Emit a separate endpoint for each HTTP method a route handles, and capture query/json/form/header/cookie/path parameters.\n- Include only endpoints the code actually serves; ignore URLs found only in comments, docs, tests, or outbound third-party calls. Do not fabricate endpoints.\n- Use finalize only when confident enough.\n- Keep endpoint method to [GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, QUERY]; use QUERY only when the code explicitly routes that verb.\n- Use param_type in [query, json, form, header, cookie, path]." -
AGENT_SHARED_RULES =
"Output only JSON. No markdown fences. Use only the defined action names. Do not guess endpoints without reading code." -
AGENT_STEP_FORMAT =
"{\n \"type\": \"json_schema\",\n \"json_schema\": {\n \"name\": \"agent_next_action\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\"list_directory\", \"read_file\", \"grep\", \"semantic_search\", \"finalize\"]\n },\n \"args\": {\n \"type\": \"object\",\n \"additionalProperties\": true\n }\n },\n \"required\": [\"action\", \"args\"],\n \"additionalProperties\": false\n },\n \"strict\": true\n }\n}" -
AGENT_TOOLS =
"[\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"list_directory\",\n \"description\": \"Return project directory structure up to max_depth.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"default\": \".\"\n },\n \"max_depth\": {\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 6,\n \"default\": 3\n }\n },\n \"additionalProperties\": false\n }\n }\n },\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"read_file\",\n \"description\": \"Read file content for endpoint extraction.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\"path\"],\n \"additionalProperties\": false\n }\n }\n },\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"grep\",\n \"description\": \"Regex search in codebase.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"pattern\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\",\n \"default\": \".\"\n },\n \"file_pattern\": {\n \"type\": \"string\",\n \"default\": \"*\"\n }\n },\n \"required\": [\"pattern\"],\n \"additionalProperties\": false\n }\n }\n },\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"semantic_search\",\n \"description\": \"Semantic-like search for code using natural language query.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\"query\"],\n \"additionalProperties\": false\n }\n }\n },\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"finalize\",\n \"description\": \"Finalize extraction and return endpoint list.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"endpoints\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": { \"type\": \"string\" },\n \"path\": { \"type\": \"string\" },\n \"method\": { \"type\": \"string\" },\n \"file\": { \"type\": \"string\" },\n \"line\": { \"type\": \"integer\" },\n \"params\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": { \"type\": \"string\" },\n \"param_type\": { \"type\": \"string\" },\n \"value\": { \"type\": \"string\" }\n },\n \"required\": [\"name\"],\n \"additionalProperties\": true\n }\n }\n },\n \"required\": [\"method\"],\n \"additionalProperties\": true\n }\n },\n \"summary\": { \"type\": \"string\" },\n \"confidence\": { \"type\": \"integer\" }\n },\n \"required\": [\"endpoints\"],\n \"additionalProperties\": true\n }\n }\n }\n]" -
ANALYZE_FORMAT =
"{\n \"type\": \"json_schema\",\n \"json_schema\": {\n \"name\": \"analyze_endpoints\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"endpoints\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\"\n },\n \"method\": {\n \"type\": \"string\"\n },\n \"params\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"param_type\": {\n \"type\": \"string\"\n },\n \"value\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\"name\", \"param_type\", \"value\"],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\"url\", \"method\", \"params\"],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\"endpoints\"],\n \"additionalProperties\": false\n },\n \"strict\": true\n }\n}" -
ANALYZE_PROMPT =
"Analyze the provided source code to extract details about the endpoints and their parameters.\n\n#{ENDPOINT_GUIDANCE}\n\nOutput format:\n- The \"method\" field should strictly use one of these values: \"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\", \"HEAD\", \"QUERY\". Use \"QUERY\" only when the code explicitly routes that verb.\n- The \"param_type\" must strictly use one of these values: \"query\", \"json\", \"form\", \"header\", \"cookie\", \"path\".\n- Do not include any explanations, comments, or additional text.\n- Output only the JSON result.\n- Return the result strictly in valid JSON format according to the schema provided below.\n\nInput Code:" -
BUNDLE_ANALYZE_PROMPT =
"Analyze the following bundle of source code files to extract details about the endpoints and their parameters.\n\n#{ENDPOINT_GUIDANCE}\n- A route prefix may be declared in one file and consumed in another within the same bundle; cross-reference files to resolve the full path.\n\nOutput format:\n- The \"method\" field should strictly use one of these values: \"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\", \"HEAD\", \"QUERY\". Use \"QUERY\" only when the code explicitly routes that verb.\n- The \"param_type\" must strictly use one of these values: \"query\", \"json\", \"form\", \"header\", \"cookie\", \"path\".\n- Include endpoints from ALL files in the bundle.\n- Do not include any explanations, comments, or additional text.\n- Output only the JSON result.\n- Return the result strictly in valid JSON format according to the schema provided below.\n\nBundle of files:" -
ENDPOINT_GUIDANCE =
"Accuracy rules:\n- Report the FULL request path a client calls. Resolve and prepend every route prefix, router/blueprint group, controller base path, or mount point (e.g. group \"/api/v1\" + route \"/users\" => \"/api/v1/users\").\n- Emit a SEPARATE endpoint object for each HTTP method a route handles; never merge methods.\n- Capture every parameter with the correct param_type: query, json (request body), form, header, cookie, or path (a URL placeholder such as {id} or :id).\n- Include only endpoints THIS code defines or serves. Ignore URLs that appear only in comments, documentation, string examples, tests, or outbound calls to third-party services.\n- Do not invent, guess, or pad endpoints. If the code defines none, return an empty endpoints list." -
Shared accuracy guidance injected into the endpoint-extraction prompts. Centralised so the classic, bundle, and agentic paths all apply the same FN/FP rules:
- Full-path resolution kills the biggest false-negative source (sub-routes whose prefix lives in a parent router/controller).
- One-object-per-method stops verb collapsing.
- The "only code that actually serves it" rule kills the biggest false-positive source (example/comment/test/outbound URLs).
-
FILTER_FORMAT =
"{\n \"type\": \"json_schema\",\n \"json_schema\": {\n \"name\": \"filter_files\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"files\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\"files\"],\n \"additionalProperties\": false\n },\n \"strict\": true\n }\n}" -
FILTER_PROMPT =
"Analyze the following list of file paths and identify which files are likely to define or serve endpoints, including API endpoints, web pages, route/controller definitions, or static resources.\n\nGuidelines:\n- Focus only on individual files.\n- Do not include directories.\n- Favor recall over precision: when a file might define routes, controllers, handlers, or served content, include it. Missing a route-bearing file loses endpoints permanently.\n- Exclude only files that clearly cannot serve endpoints (lock files, build artifacts, images, binaries).\n- Do not include any explanations, comments, or additional text.\n- Output only the JSON result.\n- Return the result strictly in valid JSON format according to the schema provided below.\n\nInput Files:" -
LEADING_FENCE =
/\A```[A-Za-z0-9_+.\-]*[^\S\n]*\r?\n?/ -
A markdown fence only ever wraps the whole payload, so both patterns are anchored. The previous
gsub("```json", "").gsub("```", "")ran over the entire response and was wrong in both directions:- backticks the model quoted from the code it was reading (inside a
snippetordescriptionstring value) were deleted from the data, silently corrupting it; - only the exact lowercase
jsontag was recognised, so```JSONor```javascriptleft the bare language word in front of the JSON —JSON.parsethen failed and the caller returned "", which the analyzer reads as "this code defines no endpoints".
The tag is matched case-insensitively and generically (any language word), since which one the model picks is not something we control.
- backticks the model quoted from the code it was reading (inside a
-
MIN_SPLIT_CHARS =
1024 -
Floor on how much of an oversized file goes into one part. A provider budget small enough to make this bind (
--ai-max-token 200) cannot fit a useful request either way; splitting into 1-line parts would just turn one doomed call into hundreds of them. -
MODEL_TOKEN_LIMITS =
{"openai" => {"gpt-3.5-turbo" => 16385, "gpt-3.5-turbo-16k" => 16385, "gpt-4" => 8192, "gpt-4-32k" => 32768, "gpt-4-turbo" => 128000, "gpt-4-turbo-preview" => 128000, "gpt-4-vision-preview" => 128000, "gpt-4-1106-preview" => 128000, "gpt-4-0125-preview" => 128000, "gpt-4o" => 128000, "gpt-4o-mini" => 128000, "o1-preview" => 128000, "o1-mini" => 128000, "o3-mini" => 200000, "gpt-5" => 1000000, "gpt-5-pro" => 1000000, "gpt-5-mini" => 1000000, "gpt-5-nano" => 1000000, "gpt-5-codex" => 1000000, "gpt-5.1" => 1000000, "gpt-5.1-codex" => 1000000, "gpt-5.1-codex-max" => 1000000, "gpt-5.1-codex-mini" => 1000000, "gpt-5.2-codex" => 1000000, "gpt-5.3-codex" => 1000000, "gpt-5.4" => 1000000, "gpt-5.4-pro" => 1000000, "gpt-5.4-mini" => 1000000, "gpt-5.4-nano" => 1000000, "gpt-5.5" => 1000000, "gpt-5.5-pro" => 1000000, "gpt-5.5-mini" => 1000000, "gpt-5.5-nano" => 1000000, "gpt-5.6-sol" => 1000000, "gpt-5.6-terra" => 1000000, "gpt-5.6-luna" => 1000000, "default" => 8000}, "xai" => {"grok-1" => 8192, "grok-2" => 131072, "grok-2-mini" => 131072, "grok-beta" => 131072, "grok-3" => 1000000, "grok-4" => 2000000, "grok-4-fast-reasoning" => 2000000, "grok-4-fast-non-reasoning" => 2000000, "grok-4.1-fast" => 2000000, "grok-4.20" => 2000000, "grok-4.3" => 2000000, "grok-4.5" => 500000, "grok-4.5-latest" => 500000, "grok-code-fast" => 2000000, "grok-code-fast-1" => 2000000, "grok-build-0.1" => 256000, "default" => 8000}, "anthropic" => {"claude-3-opus" => 200000, "claude-3-sonnet" => 200000, "claude-3-haiku" => 200000, "claude-3-5-sonnet" => 200000, "claude-3-5-haiku" => 200000, "claude-2" => 100000, "claude-2.0" => 100000, "claude-2.1" => 200000, "claude-instant-1.2" => 100000, "claude-sonnet-4" => 1000000, "claude-sonnet-4-5" => 1000000, "claude-sonnet-4-6" => 1000000, "claude-sonnet-5" => 1000000, "claude-haiku-4-5" => 200000, "claude-opus-4" => 200000, "claude-opus-4-1" => 200000, "claude-opus-4.1" => 200000, "claude-opus-4-5" => 200000, "claude-opus-4-6" => 200000, "claude-opus-4-7" => 200000, "claude-opus-4-8" => 1000000, "claude-fable-5" => 1000000, "claude-mythos-5" => 1000000, "default" => 100000}, "azure" => {"gpt-3.5-turbo" => 16385, "gpt-3.5-turbo-16k" => 16385, "gpt-4" => 8192, "gpt-4-32k" => 32768, "gpt-4-turbo" => 128000, "gpt-4-turbo-preview" => 128000, "gpt-4-vision-preview" => 128000, "gpt-4o" => 128000, "gpt-4o-mini" => 128000, "o1-preview" => 128000, "o1-mini" => 128000, "gpt-4.1" => 1000000, "gpt-5" => 1000000, "gpt-5.1-codex-max" => 1000000, "gpt-5.4" => 1000000, "gpt-5.4-mini" => 1000000, "gpt-5.5" => 1000000, "gpt-5.5-pro" => 1000000, "gpt-5.5-mini" => 1000000, "gpt-5.6-sol" => 1000000, "gpt-5.6-terra" => 1000000, "gpt-5.6-luna" => 1000000, "default" => 8000}, "github" => {"gpt-4o" => 64000, "gpt-4o-mini" => 64000, "Phi-3.5-mini-instruct" => 128000, "Phi-3.5-MoE-instruct" => 128000, "Meta-Llama-3.1-405B-Instruct" => 128000, "Meta-Llama-3.1-70B-Instruct" => 128000, "Meta-Llama-3.1-8B-Instruct" => 128000, "Mistral-large" => 128000, "Mistral-large-2407" => 128000, "Mistral-Nemo" => 128000, "Mistral-small" => 32768, "AI21-Jamba-1.5-Large" => 256000, "AI21-Jamba-1.5-Mini" => 256000, "Cohere-command-r" => 128000, "Cohere-command-r-plus" => 128000, "default" => 8000}, "ollama" => {"llama3" => 128000, "llama3.1" => 128000, "llama3.2" => 128000, "llama3.3" => 128000, "llama4" => 512000, "llama4-maverick" => 256000, "phi2" => 2048, "phi3" => 128000, "phi3.5" => 128000, "phi4" => 16384, "phi4-mini" => 128000, "gemma" => 8192, "gemma2" => 8192, "gemma3" => 128000, "gemma4" => 128000, "mistral" => 32768, "mixtral" => 32768, "codellama" => 100000, "deepseek-coder" => 100000, "qwen2" => 128000, "qwen2.5" => 128000, "qwen3" => 256000, "qwen3-coder" => 256000, "deepseek-v3" => 128000, "deepseek-v3.1" => 128000, "deepseek-v3.2" => 128000, "deepseek-v4-pro" => 1000000, "deepseek-v4-flash" => 1000000, "deepseek-r1" => 128000, "gpt-oss" => 128000, "gpt-oss-120b" => 128000, "gpt-oss-20b" => 128000, "default" => 4000}, "google" => {"gemini-1.5-pro" => 2097152, "gemini-1.5-flash" => 1048576, "gemini-1.0-pro" => 32760, "gemini-pro" => 32760, "gemini-pro-vision" => 16384, "gemini-2.0-flash-exp" => 1048576, "gemini-2.5-pro" => 2000000, "gemini-2.5-flash" => 1048576, "gemini-2.5-flash-lite" => 1048576, "gemini-3-pro-preview" => 2000000, "gemini-3.1-pro-preview" => 2000000, "gemini-3-flash-preview" => 1048576, "gemini-3.1-flash-preview" => 1048576, "gemini-3.1-flash-lite-preview" => 1048576, "gemini-3.5-flash" => 1048576, "default" => 32760}, "cohere" => {"command-r" => 256000, "command-r-plus" => 256000, "command" => 4096, "command-light" => 4096, "command-nightly" => 8192, "command-light-nightly" => 8192, "default" => 4096}, "vllm" => {"default" => 4000}, "lmstudio" => {"gpt-oss" => 128000, "gpt-oss-120b" => 128000, "gpt-oss-20b" => 128000, "default" => 4000}, "openrouter" => {"default" => 128000}, "default" => 4000} -
Map of LLM providers and their models to their max token limits This helps determine how many files can be bundled together
-
SHARED_RULES =
"Output only JSON. No explanations. method in [GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, QUERY]. param_type in [query, json, form, header, cookie, path]. Use QUERY only when the code explicitly routes that verb." -
SYSTEM_AGENT =
"#{AGENT_SHARED_RULES} You are OWASP Noir Advanced Endpoint Discovery Agent. Use iterative tool actions until enough evidence is collected, then finalize." -
SYSTEM_ANALYZE =
"#{SHARED_RULES} Given source code, return JSON with endpoints: [{url, method, params:[{name, param_type, value}]}]. Report full request paths (resolve route prefixes), one object per HTTP method, and never fabricate endpoints." -
SYSTEM_BUNDLE =
"#{SHARED_RULES} Given a bundle of files, include endpoints from ALL files; return the same JSON schema. Report full request paths (resolve route prefixes), one object per HTTP method, and never fabricate endpoints." -
SYSTEM_FILTER =
"#{SHARED_RULES} Given a list of file paths, return JSON with property files: string[] of files that may define or serve endpoints (no directories). Favor recall: when unsure, include the file." -
TRAILING_FENCE =
/\r?\n?[^\S\n]*```\s*\z/
Class Method Summary
-
.acp_max_tokens?(provider : String) : Int32 | Nil
Returns the ACP bundling budget for an
acp:<agent>provider, or nil for non-ACP providers. -
.bundle_files(files : Array(Tuple(String, String)), max_tokens : Int32, safety_margin : Float64 = 0.8) : Array(Bundle)
Create a bundle of files that fits within token limits Returns one
Bundleper request to send -
.clean_token?(value : String, max_length : Int32) : Bool
Shared shape check for LLM-supplied endpoint URLs and parameter names.
-
.estimate_tokens(text : String) : Int32
Estimate the number of tokens in a string This is a rough estimate using 1 token ≈ 4 characters for English text
-
.get_max_tokens(provider : String, model : String) : Int32
Get the maximum token limit for a given provider and model
-
.strip_json_fences(text : String) : String
Strip the markdown
json /code fences that LLM providers sometimes wrap JSON responses in, and trim surrounding whitespace.
Class Method Detail
Returns the ACP bundling budget for an acp:<agent> provider, or
nil for non-ACP providers. Kept as a standalone pure function so it
stays testable even though .get_max_tokens is monkeypatched in the
analyzer specs.
Create a bundle of files that fits within token limits
Returns one Bundle per request to send
Shared shape check for LLM-supplied endpoint URLs and parameter names. A served path or an identifier never carries raw whitespace, control characters, or markdown backticks, and stays within a sane length bound — their presence means the model captured prose or a code fragment instead. Both the AI analyzer (identification) and the LLM optimizer (correction) call this so a hallucinated value can't ride through either phase. Token-specific rules (placeholder words for URLs) stay at the call site.
Estimate the number of tokens in a string This is a rough estimate using 1 token ≈ 4 characters for English text
Get the maximum token limit for a given provider and model
Strip the markdown json / code fences that LLM providers sometimes
wrap JSON responses in, and trim surrounding whitespace. Shared by every
provider client so the cleanup rule has a single home.