From 0a44540ad63a8772b2502b2bdd837e4ed45be0bf Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 13 Apr 2026 14:18:13 +0900 Subject: [PATCH 01/65] refactor: Update inference configuration comments and remove TopP and TopK settings --- serverless.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/serverless.yml b/serverless.yml index 6e03756..1732cd4 100644 --- a/serverless.yml +++ b/serverless.yml @@ -291,9 +291,9 @@ resources: BasePromptTemplate: | {"system":"\n\n\nAgent Description:\n$instruction$\n\nAlways follow these instructions:\n- You have NO external actions, tools, APIs, or knowledge bases. Never call any action.\n- Answer every user question directly using your own general knowledge.\n- NEVER say a request is out of scope, out of domain, or beyond your tools. Always attempt a helpful answer.\n- If you truly do not know the factual answer, say so honestly in Korean, but still try to help.\n- Always generate a Thought within tags before responding. Keep it brief.\n- Provide your final response within tags. Respond in natural Korean.\n$knowledge_base_additional_guideline$\n$memory_guideline$\n$memory_content$\n$memory_action_guideline$\n$prompt_session_attributes$\n","messages":[{"role":"user","content":[{"text":"$question$"}]},{"role":"assistant","content":[{"text":"$agent_scratchpad$"}]},{"role":"assistant","content":[{"text":"Thought: \n"}]}]} InferenceConfiguration: + # Only Temperature - Claude Sonnet rejects Temperature+TopP together; + # Nova Pro is also fine with Temperature alone. Temperature: 0.3 - TopP: 0.9 - TopK: 50 MaximumLength: 2048 StopSequences: - "" From ac985fb6d8904f417aed32541c2c66a1f6da23b8 Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:28:15 +0900 Subject: [PATCH 02/65] Add logging utilities, Slack helpers, and tool registry with built-in tools - Introduced `logging_utils.py` for structured JSON logging with request ID support. - Created `slack_helpers.py` for message formatting, streaming messages, and user name caching. - Implemented `tools.py` for tool registry and six built-in tools: - `read_attached_images` - `read_attached_document` - `fetch_thread_history` - `search_web` - `generate_image` - `get_current_time` - Each tool includes JSON Schema specifications and error handling. --- .env.example | 49 +- .github/aws-role/README.md | 4 +- .github/aws-role/role-policy.json | 12 +- .github/aws-role/trust-policy.json | 5 +- .github/workflows/push.yml | 6 +- .github/workflows/sync-awsdocs.yml | 4 +- .github/workflows/sync-notion.yml | 4 +- .gitignore | 7 +- CLAUDE.md | 274 +- README.md | 311 +- app.py | 318 + handler.py | 754 -- localtest.py | 202 + package-lock.json | 7814 -------------------- package.json | 7 - pytest.ini | 6 + requirements-dev.txt | 5 + requirements.txt | 9 +- scripts/awsdocs/docs.txt | 24 - scripts/awsdocs/sync.sh | 51 - scripts/bedrock/README.md | 36 - scripts/bedrock/converse_stream.py | 124 - scripts/bedrock/invoke_agent.py | 83 - scripts/bedrock/invoke_claude_3.py | 115 - scripts/bedrock/invoke_claude_3_image.py | 90 - scripts/bedrock/invoke_knowledge_base.py | 181 - scripts/bedrock/invoke_stable_diffusion.py | 81 - scripts/bedrock/requirements.txt | 4 - scripts/notion/.env.example | 4 - scripts/notion/README.md | 31 - scripts/notion/export.py | 33 - scripts/notion/requirements.txt | 1 - scripts/split.py | 124 - serverless.yml | 376 +- src/__init__.py | 1 + src/agent.py | 185 + src/config.py | 144 + src/dedup.py | 136 + src/llm.py | 818 ++ src/logging_utils.py | 62 + src/slack_helpers.py | 368 + src/tools.py | 620 ++ 42 files changed, 3322 insertions(+), 10161 deletions(-) create mode 100644 app.py delete mode 100644 handler.py create mode 100644 localtest.py delete mode 100644 package-lock.json delete mode 100644 package.json create mode 100644 pytest.ini create mode 100644 requirements-dev.txt delete mode 100644 scripts/awsdocs/docs.txt delete mode 100755 scripts/awsdocs/sync.sh delete mode 100644 scripts/bedrock/README.md delete mode 100644 scripts/bedrock/converse_stream.py delete mode 100644 scripts/bedrock/invoke_agent.py delete mode 100644 scripts/bedrock/invoke_claude_3.py delete mode 100644 scripts/bedrock/invoke_claude_3_image.py delete mode 100644 scripts/bedrock/invoke_knowledge_base.py delete mode 100644 scripts/bedrock/invoke_stable_diffusion.py delete mode 100644 scripts/bedrock/requirements.txt delete mode 100644 scripts/notion/.env.example delete mode 100644 scripts/notion/README.md delete mode 100644 scripts/notion/export.py delete mode 100644 scripts/notion/requirements.txt delete mode 100644 scripts/split.py create mode 100644 src/__init__.py create mode 100644 src/agent.py create mode 100644 src/config.py create mode 100644 src/dedup.py create mode 100644 src/llm.py create mode 100644 src/logging_utils.py create mode 100644 src/slack_helpers.py create mode 100644 src/tools.py diff --git a/.env.example b/.env.example index ee9c624..52110f0 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,45 @@ -BOT_CURSOR=":loading:" +# --- Slack (required at runtime) --- +SLACK_BOT_TOKEN="xoxb-your-slack-bot-token" +SLACK_SIGNING_SECRET="your-slack-signing-secret" -SLACK_BOT_TOKEN="xoxb-xxxx" -SLACK_SIGNING_SECRET="xxxx" +# --- LLM / text (default: openai / gpt-4o-mini) --- +LLM_PROVIDER="openai" # openai | bedrock | xai +LLM_MODEL="gpt-4o-mini" # OpenAI: gpt-4o-mini / gpt-5 / o3 | Bedrock: us.anthropic.claude-opus-4-6-v1 / amazon.nova-pro-v1:0 | xAI: grok-4-1-fast-reasoning / grok-4.20-0309-reasoning -ALLOWED_CHANNEL_IDS="C000000,C000001" -ALLOWED_CHANNEL_MESSAGE="구루미에게 질문은 {} 채널을 이용해 주세요~" +# --- Image generation (default: openai / gpt-image-1) --- +IMAGE_PROVIDER="openai" # openai | bedrock | xai +IMAGE_MODEL="gpt-image-1" # OpenAI: gpt-image-1 / dall-e-3 | Bedrock: amazon.nova-canvas-v1:0 / amazon.titan-image-generator-v2:0 | xAI: grok-imagine-image / grok-imagine-image-pro -PERSONAL_MESSAGE="너는 AWSKRUG(AWS Korea User Group)의 친절하고 전문적인 AI 비서 구루미(Gurumi)야." -SYSTEM_MESSAGE="참고한 링크가 있다면 링크도 알려주세요. 한국어로 응답해주세요." +# --- Providers (required by provider in use) --- +OPENAI_API_KEY="sk-your-openai-api-key" +XAI_API_KEY="" # required when LLM_PROVIDER=xai or IMAGE_PROVIDER=xai. Get at https://console.x.ai +TAVILY_API_KEY="" # optional, enables richer web search -REACTION_EMOJIS="refund-done" +# --- Agent behavior --- +AGENT_MAX_STEPS=3 +MAX_OUTPUT_TOKENS=4096 # per-LLM-hop output token cap (>=256) +RESPONSE_LANGUAGE="ko" # ko | en + +# --- Slack UX --- +BOT_CURSOR=":robot_face:" +MAX_LEN_SLACK=3000 # per-message character cap, >=500 +SYSTEM_MESSAGE="" # optional base system prompt override + +# --- Access control / throttle --- +ALLOWED_CHANNEL_IDS="" # comma-separated channel IDs, empty = allow all +ALLOWED_CHANNEL_MESSAGE="" # reply sent when channel not allowed +MAX_THROTTLE_COUNT=100 # per-user active request cap + +# --- Storage (DynamoDB — created by serverless deploy) --- +DYNAMODB_TABLE_NAME="lambda-gurumi-bot-dev" +MAX_HISTORY_CHARS=4000 +AWS_REGION="us-east-1" + +# --- Observability --- +LOG_LEVEL="INFO" + +# --- Document / time --- +DEFAULT_TIMEZONE="Asia/Seoul" # IANA TZ for get_current_time default +MAX_DOC_CHARS=20000 # per-document extracted-text cap (>=1000) +MAX_DOC_PAGES=50 # per-document PDF page cap (>=1) +MAX_DOC_BYTES=26214400 # per-document download cap, 25MB (>=65536) diff --git a/.github/aws-role/README.md b/.github/aws-role/README.md index 6e1cffa..948ea9b 100644 --- a/.github/aws-role/README.md +++ b/.github/aws-role/README.md @@ -1,7 +1,7 @@ # aws role ```bash -export NAME="lambda-gurumi-ai-bot" +export NAME="lambda-gurumi-bot" ``` ## create role @@ -44,7 +44,7 @@ aws iam attach-role-policy --role-name "${NAME}" --policy-arn "${POLICY_ARN}" - name: configure aws credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/lambda-gurumi-ai-bot" + role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/lambda-gurumi-bot" role-session-name: github-actions-ci-bot aws-region: ${{ env.AWS_REGION }} diff --git a/.github/aws-role/role-policy.json b/.github/aws-role/role-policy.json index 0822a22..8db686f 100644 --- a/.github/aws-role/role-policy.json +++ b/.github/aws-role/role-policy.json @@ -26,7 +26,7 @@ "cloudformation:DeleteChangeSet", "cloudformation:DescribeChangeSet" ], - "Resource": "arn:aws:cloudformation:*:*:stack/lambda-gurumi-ai-bot-*" + "Resource": "arn:aws:cloudformation:*:*:stack/lambda-gurumi-bot-*" }, { "Sid": "Lambda", @@ -47,7 +47,7 @@ "lambda:ListTags" ], "Resource": [ - "arn:aws:lambda:*:*:function:lambda-gurumi-ai-bot-*" + "arn:aws:lambda:*:*:function:lambda-gurumi-bot-*" ] }, { @@ -65,7 +65,7 @@ "iam:UntagRole", "iam:ListRoleTags" ], - "Resource": "arn:aws:iam::*:role/lambda-gurumi-ai-bot-*" + "Resource": "arn:aws:iam::*:role/lambda-gurumi-bot-*" }, { "Sid": "S3", @@ -89,8 +89,8 @@ "s3:DeleteObject" ], "Resource": [ - "arn:aws:s3:::lambda-gurumi-ai-bot-*", - "arn:aws:s3:::lambda-gurumi-ai-bot-*/*", + "arn:aws:s3:::lambda-gurumi-bot-*", + "arn:aws:s3:::lambda-gurumi-bot-*/*", "arn:aws:s3:::gurumi-ai-bot-*", "arn:aws:s3:::gurumi-ai-bot-*/*" ] @@ -141,7 +141,7 @@ "logs:UntagResource", "logs:ListTagsForResource" ], - "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/lambda-gurumi-ai-bot-*" + "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/lambda-gurumi-bot-*" }, { "Sid": "S3Vectors", diff --git a/.github/aws-role/trust-policy.json b/.github/aws-role/trust-policy.json index 8617927..595f219 100644 --- a/.github/aws-role/trust-policy.json +++ b/.github/aws-role/trust-policy.json @@ -12,7 +12,10 @@ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { - "token.actions.githubusercontent.com:sub": "repo:awskrug/lambda-gurumi-ai-bot:*" + "token.actions.githubusercontent.com:sub": [ + "repo:awskrug/lambda-gurumi-bot:*", + "repo:nalbam/lambda-gurumi-bot:*" + ] } } } diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index e1eb80d..e363aa5 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -11,7 +11,7 @@ on: env: AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-ai-bot" + AWS_ROLE_NAME: "lambda-gurumi-bot" STAGE: "dev" @@ -93,11 +93,11 @@ jobs: - name: Update Bedrock Agent Alias 🔄 run: | AGENT_ID=$(aws cloudformation describe-stacks \ - --stack-name lambda-gurumi-ai-bot-${{ env.STAGE }} \ + --stack-name lambda-gurumi-bot-${{ env.STAGE }} \ --query "Stacks[0].Outputs[?OutputKey=='AgentId'].OutputValue" \ --output text) ALIAS_ID=$(aws cloudformation describe-stacks \ - --stack-name lambda-gurumi-ai-bot-${{ env.STAGE }} \ + --stack-name lambda-gurumi-bot-${{ env.STAGE }} \ --query "Stacks[0].Outputs[?OutputKey=='AgentAliasId'].OutputValue" \ --output text) diff --git a/.github/workflows/sync-awsdocs.yml b/.github/workflows/sync-awsdocs.yml index 87670eb..6d30bc0 100644 --- a/.github/workflows/sync-awsdocs.yml +++ b/.github/workflows/sync-awsdocs.yml @@ -7,8 +7,8 @@ on: env: AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-ai-bot" - STACK_NAME: "lambda-gurumi-ai-bot-dev" + AWS_ROLE_NAME: "lambda-gurumi-bot" + STACK_NAME: "lambda-gurumi-bot-dev" ENABLE_SYNC_AWSDOCS: ${{ vars.ENABLE_SYNC_AWSDOCS }} diff --git a/.github/workflows/sync-notion.yml b/.github/workflows/sync-notion.yml index 643b5cb..fbe5122 100644 --- a/.github/workflows/sync-notion.yml +++ b/.github/workflows/sync-notion.yml @@ -7,8 +7,8 @@ on: env: AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-ai-bot" - STACK_NAME: "lambda-gurumi-ai-bot-dev" + AWS_ROLE_NAME: "lambda-gurumi-bot" + STACK_NAME: "lambda-gurumi-bot-dev" ENABLE_SYNC_NOTION: ${{ vars.ENABLE_SYNC_NOTION }} diff --git a/.gitignore b/.gitignore index ab0bd05..b284bea 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,13 @@ dist/ # python venv -*.pyc staticfiles db.sqlite3 -__pycache__ +__pycache__/ +*.py[cod] +.coverage +.pytest_cache/ +.uploads/ # node node_modules diff --git a/CLAUDE.md b/CLAUDE.md index e35bbcd..b3de608 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,164 +1,160 @@ # CLAUDE.md -이 파일은 Claude Code가 이 저장소의 코드를 수정할 때 참고하는 개발 가이드입니다. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## 주요 명령어 +## Commands ```bash -# 배포 -sls deploy --region us-east-1 +pip install -r requirements.txt +pip install -r requirements-dev.txt +cp .env.example .env.local # fill in values + +# Local CLI runner (no Slack connection needed; streaming is default) +python localtest.py "질문" +python localtest.py --no-stream "질문" # wait for full answer, then print +python localtest.py --quiet-steps "질문" # hide intermediate step logs +python localtest.py # interactive stdin (Ctrl+D) + +# Tests +python -m pytest +python -m pytest --cov=src --cov-report=term-missing +python -m pytest tests/test_agent.py::test_agent_runs_tool_then_returns_text -v + +# Deploy (requires IAM OIDC role `lambda-gurumi-bot`) +npm i -g serverless@3 +npm i serverless-python-requirements +# export SLACK_BOT_TOKEN / SLACK_SIGNING_SECRET / OPENAI_API_KEY / ... first +serverless deploy --stage dev --region us-east-1 +``` + +Lambda entrypoint: `app.lambda_handler`. Slack events land at `POST /slack/events` via API Gateway. -# 배포 제거 -sls remove --region us-east-1 +## Core agent pipeline — DO NOT bypass or shortcut -# Bedrock 테스트 -cd scripts/bedrock && python invoke_agent.py -p "프롬프트" +Every user turn flows through the same four phases, in order: + +``` +질문 (user message) + ↓ +의도·계획 (intent + plan — one LLM hop; native function calling emits + tool_calls in the same response when tools are needed) + ↓ +툴 사용 (tool execution — repeats as the LLM keeps calling tools) + ↓ +응답 (compose the final answer once the LLM stops requesting tools) ``` -## 코드 구조 +"의도 파악" and "계획" are a single step in code: one call to +`LLMProvider.chat(..., tools=registry.specs())`. The LLM's response +carries both the interpretation of the user request AND the proposed +tool_calls (if any) in one shot. Do NOT split this into a separate +intent-classifier hop — that adds a full LLM roundtrip for no gain +and diverges from native function-calling semantics. -### handler.py - 단일 파일 구조 +**Design rules — invariants for future changes:** -모든 핵심 로직이 `handler.py`에 포함되어 있습니다. +1. **Intent is always an LLM decision.** Never use keyword heuristics + (e.g., `"그려"`/`"draw"` → image generator) to bypass the agent. + The LLM reads the message and emits `tool_calls` to reflect intent. +2. **No phase shortcuts.** Even for "obvious" image requests, we still + go through the full hop: LLM plan → `generate_image` tool_call → tool + execution → LLM compose. Skipping the compose step to save seconds + means the bot can't caption, follow up, or react to tool errors. +3. **Tool orchestration happens inside the agent loop**, not in + `app.py`. `app.py` wires Slack concerns (placeholder, streaming, + history). `src/agent.py` owns the loop. Don't push intent + detection out of the agent. +4. **Slowness is a streaming / infrastructure problem, not a + pipeline-shortcut problem.** If the loop is slow, fix it with + async invocation, model choice, or streaming UX — not by + stripping phases. -#### 클래스 +If a future change is tempted to add a keyword or rule-based intent +branch "just for images", the answer is no: route it through the +agent like everything else. -| 클래스 | 역할 | 주요 메서드 | -|--------|------|------------| -| `Config` | 환경 변수 기반 설정 (16개 항목) | `validate()`, `get_reaction_emojis()` | -| `DynamoDBManager` | 컨텍스트 저장/조회, 중복 감지, 쓰로틀링 | `put_context()`, `get_context()`, `count_user_contexts()` | -| `MessageFormatter` | 응답 분할 (코드 블록, 문단, 문장 단위) | `split_message()` | -| `SlackManager` | 메시지 업데이트, 스레드 히스토리 조회 | `update_message()`, `get_thread_history()` | -| `BedrockManager` | Agent 호출, 프롬프트 구성 | `invoke_agent()`, `create_prompt()` | +## Architecture — the non-obvious parts -#### 핸들러/함수 +### Agent loop uses NATIVE function calling, not JSON prompting -| 함수 | 트리거 | 설명 | -|------|--------|------| -| `lambda_handler` | HTTP POST `/slack/events` | Slack 이벤트 진입점 | -| `handle_mention` | `app_mention` 이벤트 | 앱 멘션 처리 | -| `handle_message` | `message` 이벤트 | 다이렉트 메시지 처리 | -| `handle_reaction_added` | `reaction_added` 이벤트 | 이모지 리액션 처리 | -| `conversation` | 내부 호출 | AI 응답 생성 및 전송 | -| `process_refund_done` | `:refund-done:` 리액션 | 계좌번호 마스킹, 환불일시 추가 | +`src/agent.py` passes `registry.specs()` directly to `LLMProvider.chat(tools=...)`. The provider (`src/llm.py`) translates that to OpenAI `tools=[{type:"function",function:{...}}]` or Bedrock `tools=[{name, description, input_schema}]` (Claude) / `toolConfig` (Nova). There is **no JSON-in-prompt parsing** — tool calls arrive as structured objects. Loop terminates when `stop_reason != "tool_use"` or `max_steps` hit. On max_steps, a forced compose step (`_compose_without_tools`) runs with `tools=None`. -#### 데이터 흐름 +Duplicate tool-call suppression: `_call_signature` = `name + sha1(args_json)`. A repeated signature within the loop is short-circuited with `{"ok": False, "error": "duplicate call skipped"}` and handed back to the LLM so it can move on. -``` -Slack 이벤트 → lambda_handler → handle_mention/handle_message - → 중복 감지 (client_msg_id, DynamoDB) - → 쓰로틀링 체크 (MAX_THROTTLE_COUNT) - → conversation() - → SlackManager.get_thread_history() → 대화 컨텍스트 수집 - → BedrockManager.create_prompt() → 프롬프트 구성 - - PERSONAL_MESSAGE (페르소나) - - SYSTEM_MESSAGE (시스템 지시) - - 태그 (대화 기록) - - 태그 (현재 질문) - → BedrockManager.invoke_agent() → Bedrock Agent 호출 - - Agent가 Knowledge Base를 자동으로 쿼리 (RAG) - → MessageFormatter.split_message() → 응답 분할 (MAX_LEN_SLACK) - → SlackManager.update_message() → Slack 전송 -``` +### Three LLM provider families, one Protocol -### serverless.yml - AWS 리소스 +`LLMProvider` is a Protocol implemented by `OpenAIProvider`, `XAIProvider`, and `BedrockProvider`. OpenAI and xAI share the OpenAI wire format, so they both extend `_OpenAICompatProvider` and reuse the module-level helpers (`_to_openai_wire_messages`, `_parse_openai_completion`, `_consume_openai_stream`) rather than duplicating stream/tool_calls handling. -#### CloudFormation 리소스 +- **OpenAIProvider**: default OpenAI endpoint. `_token_params` switches between `max_tokens` (legacy chat) and `max_completion_tokens` (gpt-5 / o1 / o3 / o4 reasoning). +- **XAIProvider**: `base_url="https://api.x.ai/v1"`, explicit `api_key`. Grok chat models accept the legacy `max_tokens + temperature` combo, so we never use `max_completion_tokens` here. Image generation omits `size` (xAI uses `aspect_ratio` / `resolution`) and always requests `response_format="b64_json"` so we can decode bytes locally. +- **BedrockProvider**: routes internally on model family prefix (Bedrock IDs and their `us./eu./apac./global.` inference-profile variants are both accepted): + - `anthropic.claude*` → `invoke_model` with Messages API shape, `content[].type=="tool_use"` parsing. + - `amazon.nova*` → `converse` / `converse_stream` with `toolConfig` + `output.message.content[].toolUse`. + - Unknown → Claude path without tools. -| 리소스 | 타입 | 이름 패턴 | -|--------|------|-----------| -| DynamoDBTable | `AWS::DynamoDB::Table` | `gurumi-ai-bot-{stage}` | -| S3Bucket | `AWS::S3::Bucket` | `gurumi-ai-bot-{account-id}` | -| S3VectorBucket | `AWS::S3Vectors::VectorBucket` | `gurumi-ai-bot-vectors-{account-id}` | -| S3VectorIndex | `AWS::S3Vectors::Index` | `gurumi-ai-bot-index` (1024dim, cosine, float32) | -| BedrockKBRole | `AWS::IAM::Role` | `lambda-gurumi-ai-bot-kb-role` | -| BedrockKnowledgeBase | `AWS::Bedrock::KnowledgeBase` | `gurumi-ai-bot-kb` | -| BedrockDataSource | `AWS::Bedrock::DataSource` | `gurumi-ai-bot-datasource` | -| BedrockAgentRole | `AWS::IAM::Role` | `lambda-gurumi-ai-bot-agent-role` | -| BedrockAgent | `AWS::Bedrock::Agent` | `gurumi-ai-bot` (Claude Sonnet 4.5, KB 연결 포함) | -| BedrockAgentAlias | `AWS::Bedrock::AgentAlias` | `live` | +`_to_anthropic_messages` / `_to_nova_messages` translate our canonical role/tool_calls/tool messages to each backend's shape. `tool` role becomes an Anthropic `tool_result` content block inside a user message; Nova becomes a `toolResult` content block. -#### Lambda IAM 권한 (iamRoleStatements) +Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; Stability uses `text_prompts`. See `_build_image_body`. -- `dynamodb:GetItem/PutItem/Query` → `gurumi-ai-bot-*` 테이블 -- `bedrock:InvokeAgent` → `agent-alias/*` +`_CompositeProvider` wraps two providers when text and image providers differ (e.g., OpenAI text + Bedrock image). -#### RAG 파이프라인 +### Slack retry → DynamoDB conditional put dedup -``` -S3 documents/ → BedrockDataSource (고정 크기 청킹: 300 토큰, 20% 오버랩) - → Titan Embeddings V2 (1024차원) → S3VectorIndex - → Bedrock Agent가 자동으로 Knowledge Base 쿼리 -``` +`lambda_handler` short-circuits when `X-Slack-Retry-Num` header is present (returns 200 OK). Even without the retry header, the first line of `_process()` is `DedupStore.reserve(f"dedup:{client_msg_id}")` which does `put_item(ConditionExpression="attribute_not_exists(id)")`. Duplicate key raises `ConditionalCheckFailedException` → False → silent return. This is the only race-safe dedup (get-then-put has a window). TTL 1h via `expire_at`. + +### Single table, two key prefixes + +`DYNAMODB_TABLE_NAME` stores both dedup reservations (`dedup:{msg_id}`) and thread conversation memory (`ctx:{thread_ts}`). GSI `user-index` (hash `user`, range `expire_at`) backs per-user throttle via `count_user_active(user)`. `ConversationStore.put` trims with `truncate_to_chars(messages, max_chars)` (drop oldest until serialized size fits). + +### Message splitting is code-fence-aware + +`MessageFormatter.split_message` (in `src/slack_helpers.py`) splits on `\`\`\`` first (so complete code blocks survive), then on `\n\n`, then on `.!?` sentence boundaries, then hard slice. `_merge_small` rejoins adjacent small chunks up to `max_len`. First chunk goes via `chat_update` on the placeholder message; the rest via `chat_postMessage(thread_ts=…)`. If `chat_update` fails (`msg_too_long` etc.), that chunk falls back to a new message. + +### Config is lazy, not import-time + +`Settings.from_env()` runs at module load but does NOT validate Slack credentials. `Settings.require_slack_credentials()` is called from `_get_bolt_app()` so the first request fails cleanly but tests and tooling can import `app` without `SLACK_BOT_TOKEN`. The old `RuntimeError` at module top is gone. + +Enum/int validation quietly falls back to defaults with a warning: invalid `LLM_PROVIDER=mystery` → `openai`, `AGENT_MAX_STEPS=not-int` → `3`, below-minimum values clamp up. + +### Streaming runs on every LLM hop + +`OpenAIProvider.chat(on_delta=...)` switches into `stream=True` and forwards content deltas as they arrive. When the model starts a `tool_calls` delta (preamble like "Let me search..."), forwarding is suppressed — that pre-tool commentary would leak into the final reply. Tool_calls are accumulated across chunks and returned alongside the content. The agent passes `self.on_stream` into every `chat()` call, so when the LLM decides to answer directly (no tools) the user sees tokens immediately. A separate `stream_chat()` path still exists for the forced compose at `max_steps` and for Bedrock paths that don't yet support tool+stream natively. + +Stream throttling is handled inside `StreamingMessage.append()` (`min_interval=0.6s`), not by a wrapper in `app.py`. `StreamingMessage` also rolls into a fresh `chat_postMessage` when the fallback buffer approaches `max_len`, and `stop()` splits an oversized final answer using `MessageFormatter` so no single update hits Slack's `msg_too_long` error. + +### Structured logging with request_id + +`src/logging_utils.py` installs a JSON handler on root. `set_request_id(uuid)` is called at the start of each `_process`. `log_event(logger, "agent.done", steps=..., tokens_in=...)` emits records whose `extra_fields` dict survives into the JSON payload — useful for CloudWatch Insights queries. Because `logging.LoggerAdapter.process()` in Python 3.12 overwrites `extra=`, `log_event` dispatches via `logger.logger` (the underlying `Logger`) instead of the adapter. + +## Deployment + +`serverless.yml` provisions: +- Lambda: python3.12, x86_64, 5120MB, 90s timeout. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) +- DynamoDB: hash `id`, GSI `user-index` (user + expire_at, KEYS_ONLY), TTL `expire_at`. +- IAM: `dynamodb:GetItem/PutItem/Query` on table + GSI, `bedrock:InvokeModel*`/`Converse*`. + +`.github/workflows/push-main.yml` runs pytest (with coverage), then `configure-aws-credentials` OIDC → `serverless deploy`. Secrets and Variables split described in README. + +## Testing + +Coverage target 80%+, currently 86% overall. Key approach: +- `moto[dynamodb]` for `DedupStore` / `ConversationStore` integration tests. +- `responses` / `unittest.mock.patch("src.tools.urllib.request.urlopen")` for web tools. +- `ScriptedLLM` (see `tests/test_agent.py`) emits predefined `LLMResult` sequences to drive loop scenarios without any network. +- Provider tests use `MagicMock` clients (no real OpenAI / Bedrock calls). +- `tests/test_config.py` builds `Settings` from `monkeypatch`-controlled env without reloading the module. + +## Things that are easy to break + +- **Dropping the `_CompositeProvider` branch** in `get_llm` breaks mixed-provider setups (OpenAI text + Bedrock image). +- **Changing `DedupStore.reserve` to a read-then-write pattern** reintroduces the retry race. +- **Losing the `id` prefix scheme** (`dedup:` vs `ctx:`) collides the two store types. +- **Switching to `LoggerAdapter.info(extra=…)`** — in Python 3.12 the adapter's `process()` overwrites `extra`; keep going through `logger.logger` for `extra_fields`. +- **Removing the SSRF host allowlist** (`SLACK_FILE_HOSTS`) shared by `read_attached_images` and `read_attached_document` (`_fetch_slack_file`) opens up arbitrary URL fetch with the bot token. +- **Adding a tool without updating `ToolRegistry.specs()`** — the `@tool` decorator handles both dispatch and LLM schema from a single declaration; inline dict tricks will silently desync. + +## Excluded (Phase 2+) -#### Outputs - -- `KnowledgeBaseId` - Bedrock Knowledge Base ID -- `DataSourceId` - Bedrock Data Source ID -- `AgentId` - Bedrock Agent ID -- `AgentAliasId` - Bedrock Agent Alias ID - -### .github/workflows/ - CI/CD - -#### push.yml - 인프라 배포 - -`main` 브랜치 푸시 시 자동 실행: -1. Python 3.12 + 의존성 설치 -2. GitHub Variables(비민감) / Secrets(민감)에서 `.env` 생성 -3. AWS OIDC 인증 (역할: `lambda-gurumi-ai-bot`) -4. `serverless deploy` (Lambda, DynamoDB, S3, S3 Vectors, KB, Agent 전체 배포) - -#### sync-notion.yml - Notion 문서 동기화 - -매일 UTC 00:00 스케줄 + 수동 실행(`workflow_dispatch`): -1. Notion 페이지를 Markdown으로 내보내기 (`notion-exporter`, 공식 Notion API) -2. S3 `documents/{page_name}/` 프리픽스로 동기화 (`aws s3 sync --delete`) -3. Knowledge Base Ingestion 실행 (문서 → 임베딩 → S3 Vectors) -4. KB/DS ID는 CloudFormation Output에서 자동 조회 -5. GitHub Secrets: `NOTION_TOKEN` (Notion Integration API 키) -6. 활성화: GitHub Variables `ENABLE_SYNC_NOTION=true` - -#### sync-awsdocs.yml - AWS 공식 문서 동기화 - -매일 UTC 01:00 스케줄 + 수동 실행(`workflow_dispatch`): -1. `scripts/awsdocs/docs.txt`에 정의된 AWS 공식 PDF 다운로드 (19개 서비스) -2. 50MB 초과 PDF는 `qpdf`로 100페이지 단위 자동 분할 -3. S3 `documents/{service}/` 프리픽스로 동기화 -4. Knowledge Base Ingestion 실행 -5. 활성화: GitHub Variables `ENABLE_SYNC_AWSDOCS=true` -6. 문서 추가/제거: `scripts/awsdocs/docs.txt` 편집 - -### .github/aws-role/role-policy.json - 배포 IAM 정책 - -배포 역할의 최소 권한 정책. 서비스별 필요 액션만 포함: -- CloudFormation, Lambda, IAM, S3, DynamoDB, API Gateway, CloudWatch Logs -- S3 Vectors, Bedrock (Knowledge Base, Data Source, Agent) - -## 환경 변수 - -| 변수명 | 기본값 | 용도 | -|--------|--------|------| -| `SLACK_BOT_TOKEN` | (필수) | Slack Bot OAuth 토큰 | -| `SLACK_SIGNING_SECRET` | (필수) | 요청 서명 검증 | -| `AGENT_ID` | (CF 자동) | Bedrock Agent ID (CloudFormation `Fn::GetAtt` 참조) | -| `AGENT_ALIAS_ID` | (CF 자동) | Bedrock Agent Alias ID (CloudFormation `Fn::GetAtt` 참조) | -| `DYNAMODB_TABLE_NAME` | `gurumi-ai-bot-dev` | DynamoDB 테이블명 | -| `AWS_REGION` | `us-east-1` | AWS 리전 | -| `ALLOWED_CHANNEL_IDS` | `None` | 허용 채널 (쉼표 구분, None=전체 허용) | -| `ALLOWED_CHANNEL_MESSAGE` | 영문 메시지 | 비허용 채널 응답 메시지 | -| `PERSONAL_MESSAGE` | `You are a friendly and professional AI assistant.` | 페르소나 프롬프트 | -| `SYSTEM_MESSAGE` | `None` | 시스템 지시사항 | -| `MAX_LEN_SLACK` | `2000` | Slack 메시지 분할 길이 | -| `MAX_LEN_BEDROCK` | `4000` | Bedrock 컨텍스트 최대 길이 | -| `MAX_THROTTLE_COUNT` | `100` | 사용자별 동시 활성 컨텍스트 수 제한 | -| `SLACK_SAY_INTERVAL` | `0` | 분할 메시지 전송 간격 (초) | -| `BOT_CURSOR` | `:robot_face:` | 로딩 표시 이모지 | -| `REACTION_EMOJIS` | `refund-done` | 허용 이모지 리액션 (쉼표 구분) | - -## 코드 수정 시 주의사항 - -- `handler.py`는 단일 파일 구조. 800줄 이상 시 클래스 단위 분리 검토 -- `serverless.yml`의 리소스 이름 패턴(`gurumi-ai-bot-*`, `lambda-gurumi-ai-bot-*`)은 `role-policy.json`의 IAM 리소스 패턴과 일치해야 함 -- Bedrock Agent는 CloudFormation으로 관리 (`AWS::Bedrock::Agent`). `AGENT_ID`/`AGENT_ALIAS_ID`는 `Fn::GetAtt` 참조 -- Knowledge Base는 Agent에 연결되므로 `handler.py`에서 직접 Retrieve API를 호출하지 않음 -- DynamoDB TTL은 `expire_at` (Unix timestamp) 속성 사용, 1시간 기본 +- Bedrock Knowledge Base (S3 Vectors + RAG) ingestion pipeline +- `reaction_added` event wiring + domain-specific handlers (refund masking, etc.) +- CloudWatch Alarms / X-Ray / multi-language prompts beyond ko/en diff --git a/README.md b/README.md index b8f50e9..b972310 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,213 @@ -# lambda-gurumi-ai-bot +# lambda-gurumi-bot -AWS Lambda, Amazon Bedrock, S3 Vectors를 활용한 서버리스 AI 챗봇입니다. RAG(Retrieval-Augmented Generation) 기반으로 문서를 검색하여 답변합니다. +Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xAI(Grok) LLM 으로 네이티브 **function calling** 기반 툴 오케스트레이션을 수행하는 봇입니다. -![Gurumi Bot](images/gurumi-bot.png) +## 봇의 처리 흐름 (절대 생략하지 않는다) -## 주요 기능 - -- **RAG 지원**: S3 Vectors + Bedrock Knowledge Base 기반 문서 검색 및 답변 -- **서버리스 아키텍처**: AWS Lambda + API Gateway + DynamoDB -- **대화 히스토리**: DynamoDB를 통한 스레드 컨텍스트 유지 (1시간 TTL) -- **Slack 통합**: 앱 멘션, 다이렉트 메시지, 이모지 리액션 지원 -- **채널 접근 제어**: 허용된 채널 화이트리스트 -- **사용자 쓰로틀링**: 남용 방지를 위한 요청 제한 -- **응답 분할**: 긴 응답을 코드 블록/문단 단위로 분할 전송 - -## 아키텍처 +모든 사용자 메시지는 다음 네 단계를 **순서대로** 통과합니다: ``` -┌──────────┐ ┌─────────────┐ ┌─────────────┐ -│ Slack │────▶│ API Gateway │────▶│ Lambda │ -└──────────┘ └─────────────┘ └──────┬──────┘ - │ - ┌─────────────────────────────┼──────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ - │ DynamoDB │ │ Bedrock Agent │ │ S3 │ - │ (Context) │ │ │ │ │ (Documents) │ - └─────────────┘ │ Knowledge Base │ └──────┬──────┘ - │ │ │ │ - │ S3 Vectors │◀────────┘ - │ (Embeddings) │ Titan Embeddings V2 - └──────────────────┘ +질문 ── 의도·계획 ── 툴 사용 (반복) ── 응답 + (user) (LLM) (tools) (LLM) ``` -## 설치 - -```bash -# Python 3.12 설치 -brew install python@3.12 +**의도 파악과 계획은 한 번의 LLM 호출로 통합**되어 있습니다 (OpenAI / Claude / Nova 의 native function calling). 같은 응답에 "무슨 요청인지 파악한 결과" 와 "다음에 부를 tool_calls" 가 함께 담겨 옵니다. 별도의 intent 분류 hop 을 추가하지 않습니다. -# Serverless Framework 설치 -npm install -g serverless@3.38.0 +- **의도·계획은 LLM 이 한다.** 키워드 매칭(예: `"그려"` → 이미지)으로 우회하지 않는다. LLM 이 메시지를 읽고 `tool_calls` 로 의도를 표현한다. +- **단계 단축 금지.** 이미지 요청처럼 명확해 보여도 `LLM 판단 → generate_image tool → LLM 응답 합성` 전 과정을 거친다. 응답 합성 단계를 건너뛰면 caption·후속 대응·에러 처리가 사라진다. +- **Agent 루프는 `src/agent.py` 안에** 있고, `app.py` 는 Slack 관련 부분(placeholder, streaming, 히스토리) 만 담당한다. +- **속도 문제는 파이프라인 단축이 아닌** 스트리밍·비동기·모델 선택으로 해결한다. -# 프로젝트 의존성 설치 -npm install -sls plugin install -n serverless-python-requirements -sls plugin install -n serverless-dotenv-plugin -python -m pip install --upgrade -r requirements.txt -``` +## 주요 기능 -## 설정 +- **이벤트**: `app_mention`, DM(`message.im`) +- **Provider**: OpenAI · AWS Bedrock(Anthropic Claude 3/3.5/4.x · Amazon Nova) · xAI(Grok) 선택 가능 +- **Tools (네이티브 function calling)** + - `read_attached_images` — 첨부 이미지 Vision 요약 + - `fetch_thread_history` — 스레드 히스토리 조회 + - `search_web` — Tavily (TAVILY_API_KEY 설정 시) 또는 DuckDuckGo + - `generate_image` — 이미지 생성 후 Slack 업로드 + - `get_current_time` — 서버 기본 TZ(또는 `timezone` 인자) 로 현재 시각/요일 반환 + - `read_attached_document` — 첨부 PDF/텍스트 파일 추출 (페이지·바이트·문자 상한 적용) +- **Production 기반** + - DynamoDB 조건부 put 으로 Slack 재시도 **중복 제거** + - 채널 allowlist · 유저당 동시 요청 **throttle** + - DynamoDB 기반 **스레드 대화 메모리** (TTL 1h) + - 긴 응답 **계층적 분할** 전송 (코드블록 → 문단 → 문장 → hard slice), `chat.update` 가 `msg_too_long` 에 걸리지 않도록 `MAX_LEN_SLACK` 기반 rolling 스트리밍 + 최종 답변 자동 split + - 스트리밍 `chat_postMessage` + 반복 `chat_update` fallback (네이티브 `chat.startStream`/`appendStream`/`stopStream` 은 AI 워크스페이스에서 추가 "searching" 상태 UI 를 띄워 두 개의 응답처럼 보이는 이슈 때문에 기본 비활성화, `enable_native=True` 로만 사용). **회신 데이터가 없을 때** (thinking / tool_use / tool_result 단계) 는 `assistant_threads_setStatus` 로 타이핑 인디케이터만 표시하고, **첫 content delta 가 도착한 시점에** 비로소 `stream_msg.start()` 를 지연 호출해 placeholder 메시지를 posting — 상태 UI 와 placeholder 가 동시에 뜨지 않아 두 개의 응답으로 보이던 이슈 해소. + - 구조화 JSON 로깅 + request_id, agent 루프 관찰값 기록 + - 에러 메시지 sanitize (토큰·경로 redaction) + +## 환경 변수 + +| 변수 | 필수 | 기본값 | 설명 | +|------|------|--------|------| +| `SLACK_BOT_TOKEN` | ✅ | — | `xoxb-…` | +| `SLACK_SIGNING_SECRET` | ✅ | — | Slack Signing Secret | +| `OPENAI_API_KEY` | OpenAI 사용 시 | — | OpenAI API 키 | +| `XAI_API_KEY` | xAI 사용 시 | — | xAI (Grok) API 키 — https://console.x.ai | +| `TAVILY_API_KEY` | | — | 설정 시 Tavily 웹 검색 활성화 | +| `LLM_PROVIDER` | | `openai` | `openai` / `bedrock` / `xai` | +| `LLM_MODEL` | | `gpt-4o-mini` | 텍스트 모델 | +| `IMAGE_PROVIDER` | | `openai` | `openai` / `bedrock` / `xai` | +| `IMAGE_MODEL` | | `gpt-image-1` | 이미지 모델 | +| `AGENT_MAX_STEPS` | | `3` | tool 루프 최대 iteration | +| `RESPONSE_LANGUAGE` | | `ko` | `ko` / `en` | +| `DYNAMODB_TABLE_NAME` | | `lambda-gurumi-bot-dev` | dedup / 대화 저장 테이블 | +| `AWS_REGION` | | `us-east-1` | AWS 리전 | +| `ALLOWED_CHANNEL_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 채널 허용 | +| `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 | +| `MAX_LEN_SLACK` | | `3000` | 메시지 분할 기준 (≥500). Slack `chat.update` 의 한계 회피용 안전 margin. `.env.example` / `serverless.yml` 기본값. 변수 자체가 미설정이면 `config.py` fallback `2000`. | +| `MAX_OUTPUT_TOKENS` | | `4096` | LLM hop 당 출력 토큰 상한 (≥256) | +| `MAX_THROTTLE_COUNT` | | `100` | 유저별 동시 요청 상한 | +| `MAX_HISTORY_CHARS` | | `4000` | 저장되는 대화 직렬화 최대 길이 | +| `DEFAULT_TIMEZONE` | | `Asia/Seoul` | `get_current_time` 기본 TZ (IANA). 잘못된 이름이면 기본값으로 폴백 + 경고 | +| `MAX_DOC_CHARS` | | `20000` | `read_attached_document` 추출 텍스트 최대 문자수 (≥1000) | +| `MAX_DOC_PAGES` | | `50` | `read_attached_document` PDF 최대 페이지수 (≥1) | +| `MAX_DOC_BYTES` | | `26214400` | `read_attached_document` 다운로드 최대 바이트 (기본 25MB, ≥65536) | +| `BOT_CURSOR` | | `:robot_face:` | 플레이스홀더·스트림 인디케이터 이모지 | +| `SYSTEM_MESSAGE` | | — | 시스템 프롬프트 오버라이드 | +| `LOG_LEVEL` | | `INFO` | 로그 레벨 | + +## 모델 매트릭스 + +| 용도 | OpenAI | Bedrock | xAI (Grok) | +|------|--------|---------|------------| +| 텍스트 + tool calling | `gpt-4o-mini`, `gpt-4o`, `gpt-5-*`, `o1/o3/o4` | `us.anthropic.claude-opus-4-6-v1`, `us.anthropic.claude-sonnet-4-5-...`, `amazon.nova-pro-v1:0` | `grok-4-1-fast-reasoning`, `grok-4.20-0309-reasoning`, `grok-4.20-multi-agent-0309` | +| 이미지 생성 | `gpt-image-1`, `dall-e-3` | `amazon.nova-canvas-v1:0`, `amazon.titan-image-generator-v2:0` | `grok-imagine-image`, `grok-imagine-image-pro` | + +- Claude 는 Messages API (`tools=[{name, description, input_schema}]`), Nova 는 Converse API (`toolConfig`) 로 자동 분기됩니다. +- xAI 는 OpenAI wire 호환이라 OpenAI Python SDK 에 `base_url="https://api.x.ai/v1"` 만 swap 해서 호출합니다. 별도 `XAIProvider` 클래스로 분리되어 있습니다. +- Bedrock 최신 모델은 `us./eu./apac./global.` inference-profile prefix 가 붙은 ID 로만 호출됩니다. `BedrockProvider` 가 자동 인식합니다. + +## 로컬 개발 -### Slack 앱 설정 +```bash +pip install -r requirements.txt +pip install -r requirements-dev.txt -[Slack Bolt for Python 시작 가이드](https://slack.dev/bolt-python/tutorial/getting-started)를 참고하여 Slack 앱을 생성합니다. +cp .env.example .env.local # 값 채우기 -#### OAuth & Permissions - Bot Token Scopes +# CLI 실행 (스트리밍이 기본값) +python localtest.py "오늘 서울 날씨" +python localtest.py --no-stream "React 훅 설명해줘" # 전체 답변을 한 번에 출력 +python localtest.py --quiet-steps "…" # 중간 step 로그 숨김 +python localtest.py # 대화형 (stdin, Ctrl+D) -```text -app_mentions:read -channels:history -channels:join -channels:read -chat:write -files:read -files:write -im:read -im:write -reactions:read -users:read +# 테스트 +python -m pytest --cov=src --cov-report=term-missing ``` -#### Event Subscriptions - Subscribe to bot events +`.env.local` 은 `src/config.py` 가 python-dotenv 로 자동 로드합니다. `SLACK_BOT_TOKEN` 이 placeholder 이면 `localtest.py` 가 Slack 호출을 stub 으로 대체합니다. -```text -app_mention -message.im -reaction_added -``` +## 배포 (Serverless Framework v3) + +### 1. IAM OIDC role 준비 (한 번만) -### 환경 변수 +`role/lambda-gurumi-bot` 을 AWS 계정에 생성하고 GitHub OIDC trust + 배포용 policy 를 연결합니다. 템플릿과 상세 절차는 `.github/aws-role/` 에 있습니다: ```bash -cp .env.example .env.local +cd .github/aws-role +export NAME="lambda-gurumi-bot" +aws iam create-role --role-name "${NAME}" --assume-role-policy-document file://trust-policy.json +aws iam create-policy --policy-name "${NAME}" --policy-document file://role-policy.json +export ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account) +aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/${NAME}" ``` -#### 필수 설정 (GitHub Secrets) - -| 변수명 | 설명 | -|--------|------| -| `SLACK_BOT_TOKEN` | Slack Bot OAuth 토큰 (`xoxb-xxxx`) | -| `SLACK_SIGNING_SECRET` | Slack 요청 서명 검증용 시크릿 | -| `NOTION_TOKEN` | Notion Integration API 키 ([생성](https://www.notion.so/my-integrations)) | +`trust-policy.json` 은 `nalbam/lambda-gurumi-bot` repo 의 OIDC 토큰을, `role-policy.json` 은 CloudFormation / Lambda / IAM / S3 / DynamoDB / API Gateway / CloudWatch Logs 권한을 (`lambda-gurumi-bot-*` 스코프) 포함합니다. -> `AGENT_ID`/`AGENT_ALIAS_ID`는 CloudFormation에서 자동 관리됩니다. +### 2. GitHub 저장소 설정 -#### 선택적 설정 +- **Secrets**: `AWS_ACCOUNT_ID`, `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `OPENAI_API_KEY`, `XAI_API_KEY`(xAI 사용 시), `TAVILY_API_KEY`(선택) +- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `SYSTEM_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES` -| 변수명 | 기본값 | 설명 | -|--------|--------|------| -| `AWS_REGION` | `us-east-1` | AWS 리전 | -| `DYNAMODB_TABLE_NAME` | `gurumi-ai-bot-dev` | DynamoDB 테이블명 | -| `ALLOWED_CHANNEL_IDS` | `None` | 허용 채널 ID (쉼표 구분) | -| `PERSONAL_MESSAGE` | 일반 AI 어시스턴트 | AI 페르소나 설정 | -| `SYSTEM_MESSAGE` | `None` | 추가 시스템 지시사항 | -| `MAX_LEN_SLACK` | `2000` | Slack 메시지 최대 길이 | -| `MAX_LEN_BEDROCK` | `4000` | Bedrock 컨텍스트 최대 길이 | -| `MAX_THROTTLE_COUNT` | `100` | 사용자별 요청 제한 수 | -| `BOT_CURSOR` | `:robot_face:` | 로딩 표시 이모지 | -| `REACTION_EMOJIS` | `refund-done` | 허용 이모지 리액션 (쉼표 구분) | +### 3. 배포 -## 배포 +`main` 브랜치에 push 하면 `.github/workflows/push-main.yml` 이 pytest → Serverless deploy 순으로 수행합니다. 수동 실행은 `workflow_dispatch`. ```bash -# 기본 배포 (dev 스테이지) -sls deploy --region us-east-1 - -# 프로덕션 배포 -sls deploy --stage prod --region us-east-1 - -# 배포 제거 -sls remove --region us-east-1 +# 로컬 배포 (선택) +npm i -g serverless@3 && npm i serverless-python-requirements +# Secrets + Variables 를 현재 셸에 export 한 뒤 +serverless deploy --stage dev --region us-east-1 ``` -### RAG 문서 추가 - -배포 후 S3 버킷의 `documents/` 프리픽스에 문서를 업로드하고 동기화합니다. +DynamoDB 테이블 (해시키 `id`, GSI `user-index`, TTL `expire_at`) 은 CloudFormation 이 생성합니다. -```bash -# 문서 업로드 (PDF, TXT, MD, HTML, DOCX, CSV 지원) -aws s3 cp my-document.pdf s3://gurumi-ai-bot-{account-id}/documents/ +## 아키텍처 -# Knowledge Base 동기화 -aws bedrock-agent start-ingestion-job \ - --knowledge-base-id \ - --data-source-id +``` +┌────────────────┐ POST /slack/events +│ Slack workspace│──────────────────┐ +└────────────────┘ ▼ + ┌───────────────────────────────────┐ + │ API Gateway → Lambda (app.py) │ + │ ├─ X-Slack-Retry-Num early return │ + │ └─ SlackRequestHandler (Bolt) │ + └────────┬───────────────────┬──────┘ + │ │ + ┌──────────▼─────────┐ ┌──────▼─────────┐ + │ app_mention handler│ │ message handler│ + └──────────┬─────────┘ └──────┬─────────┘ + └──────┬────────────┘ + ▼ + ┌───────────────────────────────────────────┐ + │ _process() │ + │ 1. DedupStore.reserve (conditional put) │ + │ 2. channel_allowed / throttle │ + │ 3. set_thread_status + placeholder say │ + │ 4. ConversationStore.get → history │ + │ 5. SlackMentionAgent.run ──┐ │ + │ 6. send_long_message │ │ + │ 7. ConversationStore.put │ │ + └─────────────────────────────┼─────────────┘ + │ + ┌───────────────────────▼───────────────┐ + │ Agent loop (native function calling) │ + │ LLM.chat(messages, tools=registry) │ + │ ↓ tool_calls? │ + │ ToolExecutor.execute (per-call t/o) │ + │ ↓ role=tool result │ + │ (loop up to AGENT_MAX_STEPS) │ + │ streaming chat_update on final step │ + └────────────┬──────────────────────────┘ + │ + ┌───────────────┼────────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌────────────┐ ┌──────────────┐ + │ OpenAI │ │ Bedrock │ │ Slack Web API│ + │ Chat API │ │ Messages / │ │ (tools) │ + │ Vision │ │ Converse │ └──────────────┘ + └───────────┘ └────────────┘ + ▲ + │ + ┌──────┴─────┐ + │ DynamoDB │ + │ (dedup+ctx)│ + └────────────┘ ``` -### 문서 동기화 - -두 가지 자동 동기화 워크플로우가 있으며, GitHub Variables로 활성화합니다. - -| 워크플로우 | 소스 | 활성화 변수 | -|-----------|------|------------| -| `sync-notion.yml` | Notion 페이지 → Markdown | `ENABLE_SYNC_NOTION=true` | -| `sync-awsdocs.yml` | AWS 공식 PDF (19개 서비스) | `ENABLE_SYNC_AWSDOCS=true` | - -AWS 문서 목록은 `scripts/awsdocs/docs.txt`에서 관리합니다. - -### CI/CD +## Lambda 엔트리포인트 -GitHub Actions (`push.yml`)로 `main` 브랜치 푸시 시 자동 배포됩니다. +- `app.lambda_handler` -## 테스트 +## Production 체크리스트 (수동 확인용) -```bash -# Slack URL 검증 -curl -X POST \ - -H "Content-Type: application/json" \ - -d '{"token": "test", "challenge": "test_challenge", "type": "url_verification"}' \ - https://your-api-url/dev/slack/events - -# Bedrock Agent 직접 테스트 -cd scripts/bedrock -python invoke_agent.py -p "프롬프트 입력" -python invoke_knowledge_base.py -p "지식 베이스 쿼리" -``` +- [ ] `@` 멘션에 정상 응답 +- [ ] DM 대화에 응답 +- [ ] 긴 응답이 여러 청크로 쪼개져 스레드에 전송됨 +- [ ] 이미지 생성 요청 (`"고양이 그려줘"`) 이 업로드됨 +- [ ] 이미지 첨부 요약 (`read_attached_images`) 동작 +- [ ] PDF/텍스트 첨부 요약 (`read_attached_document`) 이 페이지·문자 상한 안에서 동작 (암호화 PDF 는 오류 반환) +- [ ] `ALLOWED_CHANNEL_IDS` 외 채널에서 차단 메시지 표시 +- [ ] Slack retry 중복 호출이 dedup 로 무시됨 (CloudWatch 로그에 `dedup.skip` 확인) +- [ ] 툴 실행 중 타이핑 인디케이터 (`assistant_threads_setStatus`) 만 뜨고 placeholder 메시지는 아직 올라오지 않음 (회신 데이터가 없을 때) +- [ ] 회신 생성이 시작되면 단일 메시지가 올라와 스트리밍으로 업데이트 됨 (상태 UI 와 placeholder 가 동시에 보이지 않음) +- [ ] 같은 스레드 재멘션 시 이전 대화 맥락 참조 -## 참고 자료 +## 제외된 항목 (Phase 2 이상) -- [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/) -- [Amazon S3 Vectors](https://aws.amazon.com/s3/features/vectors/) -- [Slack Bolt for Python](https://slack.dev/bolt-python/) -- [Serverless Framework](https://www.serverless.com/) +- Bedrock Knowledge Base (RAG) 통합 +- `reaction_added` 이벤트 훅 및 도메인 특화 로직 +- CloudWatch Alarms / X-Ray tracing diff --git a/app.py b/app.py new file mode 100644 index 0000000..4a3866b --- /dev/null +++ b/app.py @@ -0,0 +1,318 @@ +"""AWS Lambda entrypoint for the Slack mention bot. + +Flow per request: + 1. lambda_handler short-circuits Slack retries (X-Slack-Retry-Num header). + 2. Bolt dispatches event to app_mention / message handler. + 3. Handler acks immediately, then: + - Deduplicates on client_msg_id via DynamoDB conditional put. + - Checks channel allowlist + per-user throttle. + - Sets typing status + sends a placeholder message. + - Loads thread history from DynamoDB and runs the native-tool-calling agent. + - Streams the final answer into `chat_update` (first chunk) + `chat_postMessage` (rest). + - Persists updated conversation back to DynamoDB. +""" +from __future__ import annotations + +import re +import uuid + +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + +from src.agent import SlackMentionAgent +from src.config import Settings +from src.dedup import ConversationStore, DedupStore +from src.llm import get_llm +from src.logging_utils import get_logger, log_event, set_request_id +from src.slack_helpers import ( + MessageFormatter, + StreamingMessage, + channel_allowed, + sanitize_error, + set_thread_status, + user_name_cache, +) + + +from src.tools import ToolContext, default_registry + +settings = Settings.from_env() +logger = get_logger("app") + +_llm = None +_dedup: DedupStore | None = None +_conversations: ConversationStore | None = None +_bolt_app: App | None = None + + +LABELS = { + "ko": { + "generated_image": "생성된 이미지", + "error_prefix": "요청 처리 중 오류가 발생했습니다", + "throttled": "잠시 후 다시 시도해주세요. 처리 중인 요청이 많습니다.", + "thinking": "생각 중... ", + "max_steps": "답변 정리 중... ", + "using_tools": "도구 사용 중: {tools}", + "tool_ok": "도구 완료: {tool}", + "tool_failed": "도구 실패: {tool}", + "composing": "답변 작성 중...", + }, + "en": { + "generated_image": "Generated image", + "error_prefix": "An error occurred while processing your request", + "throttled": "Too many in-flight requests. Please try again shortly.", + "thinking": "Thinking... ", + "max_steps": "Finalizing... ", + "using_tools": "Running tools: {tools}", + "tool_ok": "Finished: {tool}", + "tool_failed": "Failed: {tool}", + "composing": "Composing the answer...", + }, +} + + +def _labels() -> dict[str, str]: + return LABELS.get(settings.response_language, LABELS["en"]) + + +def _get_llm(): + global _llm + if _llm is None: + _llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={"xai": settings.xai_api_key}, + ) + return _llm + + +def _get_dedup() -> DedupStore: + global _dedup + if _dedup is None: + _dedup = DedupStore(table_name=settings.dynamodb_table_name, region=settings.aws_region) + return _dedup + + +def _get_conversations() -> ConversationStore: + global _conversations + if _conversations is None: + _conversations = ConversationStore(table_name=settings.dynamodb_table_name, region=settings.aws_region) + return _conversations + + +def _get_bolt_app() -> App: + global _bolt_app + if _bolt_app is not None: + return _bolt_app + settings.require_slack_credentials() + app = App( + token=settings.slack_bot_token, + signing_secret=settings.slack_signing_secret, + process_before_response=True, + ) + + @app.event("app_mention") + def _on_mention(event, client, say, ack): # noqa: ANN001 + ack() + _process(event, client, say, is_dm=False) + + @app.event("message") + def _on_message(event, client, say, ack): # noqa: ANN001 + ack() + if event.get("channel_type") != "im": + return + if event.get("bot_id") or event.get("subtype"): + return + _process(event, client, say, is_dm=True) + + _bolt_app = app + return _bolt_app + + +MENTION_RE = re.compile(r"<@[^>]+>") + + +def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 + set_request_id(str(uuid.uuid4())) + labels = _labels() + text = MENTION_RE.sub("", event.get("text", "")).strip() + channel = event.get("channel") + thread_ts = event.get("thread_ts") or event.get("ts") + user = event.get("user", "") + + dedup = _get_dedup() + dedup_key = event.get("client_msg_id") or f"{channel}:{event.get('ts')}" + try: + if not dedup.reserve(f"dedup:{dedup_key}", user=user or "system"): + log_event(logger, "dedup.skip", key=dedup_key) + return + except Exception as exc: # noqa: BLE001 + logger.warning("dedup unavailable, proceeding without it: %s", exc) + + if not channel_allowed(channel, settings.allowed_channel_ids): + msg = settings.allowed_channel_message or "" + if msg: + say(text=msg, thread_ts=thread_ts) + log_event(logger, "channel.blocked", channel=channel) + return + + try: + active = dedup.count_user_active(user) + except Exception as exc: # noqa: BLE001 + logger.warning("throttle count unavailable: %s", exc) + active = 0 + if active >= settings.max_throttle_count: + say(text=labels["throttled"], thread_ts=thread_ts) + log_event(logger, "throttle.limit", user=user, active=active) + return + + if not text: + return + + # Show a typing-style status indicator while the bot is "working" with + # nothing to reply yet. We intentionally do NOT post a placeholder + # chat.postMessage up front: that would render as a separate UI element + # alongside the status line (a duplicate-message look on AI workspaces). + # The placeholder is posted lazily in _on_stream_wrapped once the first + # real content delta arrives. Slack auto-clears the status when the bot + # posts in the thread; we also explicitly clear it after we finalize. + set_thread_status(client, channel, thread_ts, labels["thinking"] + settings.bot_cursor) + + stream_msg = StreamingMessage( + client=client, + channel=channel, + thread_ts=thread_ts, + placeholder=settings.bot_cursor, + min_interval=0.6, + max_len=settings.max_len_slack, + ) + + def _on_stream_wrapped(delta: str) -> None: + """Defer placeholder posting until the first real content arrives.""" + if not delta: + return + if stream_msg.ts is None: + try: + stream_msg.start() + except Exception as exc: # noqa: BLE001 + logger.warning("deferred streaming message start failed: %s", exc) + return + stream_msg.append(delta) + + history_store = _get_conversations() + history = history_store.get(thread_ts) + + llm = _get_llm() + context = ToolContext( + slack_client=client, + channel=channel, + thread_ts=thread_ts, + event=event, + settings=settings, + llm=llm, + ) + + def _on_step(step_num: int, phase: str, detail: dict) -> None: + # While no message is posted yet, use assistant_threads.setStatus. + # Once the stream has started (stream_msg.ts is set), the bot message + # is already visible — skip status updates to avoid re-triggering the + # duplicate-UI problem. + if stream_msg.ts is not None: + return + if phase == "tool_use": + tools = ", ".join(detail.get("tools") or []) + status = labels["using_tools"].format(tools=tools) + elif phase == "tool_result": + key = "tool_ok" if detail.get("ok") else "tool_failed" + status = labels[key].format(tool=detail.get("tool") or "") + elif phase == "compose": + status = labels["max_steps"] if detail.get("max_steps_hit") else labels["composing"] + else: + return + set_thread_status(client, channel, thread_ts, status + " " + settings.bot_cursor) + + agent = SlackMentionAgent( + llm=llm, + context=context, + registry=default_registry, + max_steps=settings.agent_max_steps, + response_language=settings.response_language, + system_message=settings.system_message, + history=history, + on_stream=_on_stream_wrapped, + on_step=_on_step, + max_output_tokens=settings.max_output_tokens, + ) + + user_name = user_name_cache.get(client, user) if user else "" + log_event(logger, "agent.start", user=user_name or user, channel=channel, is_dm=is_dm) + + try: + result = agent.run(text) + except Exception as exc: # noqa: BLE001 + logger.exception("agent failure") + error_text = f"{labels['error_prefix']}: {sanitize_error(exc)}" + if stream_msg.ts is not None: + stream_msg.stop(error_text) + else: + say(text=error_text, thread_ts=thread_ts) + set_thread_status(client, channel, thread_ts, "") + return + + final_text = result.text or "(응답을 생성하지 못했습니다)" + # Split the answer by Slack's per-message limit. StreamingMessage.stop() + # handles split internally when a placeholder exists; when it doesn't + # (no stream deltas ever arrived — e.g. a provider that returned content + # all at once), we post the chunks as fresh thread messages instead. + chunks = MessageFormatter.split_message(final_text, max_len=settings.max_len_slack) + if stream_msg.ts is not None: + stream_msg.stop(chunks[0]) + else: + client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=chunks[0]) + for extra in chunks[1:]: + client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=extra) + # Explicitly clear the typing-style status indicator. Slack usually + # auto-clears it when the bot posts a reply, but an explicit clear + # ensures there's no stale line left over from the last on_step update. + set_thread_status(client, channel, thread_ts, "") + # NOTE: do not post `result.image_url` as a separate text message — + # the image is already uploaded inline to the thread by the + # generate_image tool, and the LLM's reply is instructed to omit + # the permalink. A trailing "생성된 이미지: " line would just + # duplicate what the user already sees. + + new_history = [ + *history, + {"role": "user", "content": text}, + {"role": "assistant", "content": final_text}, + ] + try: + history_store.put( + thread_ts, + user=user or "unknown", + messages=new_history, + max_chars=settings.max_history_chars, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("conversation persist failed: %s", exc) + + log_event( + logger, + "agent.done", + steps=result.steps, + tool_calls=result.tool_calls_count, + tokens_in=result.token_usage.get("input", 0), + tokens_out=result.token_usage.get("output", 0), + ) + + +def lambda_handler(event, context): # noqa: ANN001 + # Short-circuit Slack retries without re-running the agent. + headers = event.get("headers") or {} + normalized = {k.lower(): v for k, v in headers.items()} + if normalized.get("x-slack-retry-num"): + return {"statusCode": 200, "body": ""} + return SlackRequestHandler(_get_bolt_app()).handle(event, context) diff --git a/handler.py b/handler.py deleted file mode 100644 index ecbcc89..0000000 --- a/handler.py +++ /dev/null @@ -1,754 +0,0 @@ -import boto3 -import json -import os -import re -import time -from datetime import datetime, timezone, timedelta -from typing import List, Optional, Dict, Any, Union - -from slack_bolt import App, Say -from slack_bolt.adapter.aws_lambda import SlackRequestHandler - -from boto3.dynamodb.conditions import Key - - -# Helper functions for environment variable parsing -def get_env_int(key: str, default: int) -> int: - """Get environment variable as integer, with fallback for empty strings""" - value = os.environ.get(key, "") - return int(value) if value else default - - -def get_env_float(key: str, default: float) -> float: - """Get environment variable as float, with fallback for empty strings""" - value = os.environ.get(key, "") - return float(value) if value else default - - -def get_env_str(key: str, default: str) -> str: - """Get environment variable as string, with fallback for empty strings""" - value = os.environ.get(key, "") - return value if value else default - - -# Environment configuration -class Config: - """Configuration settings loaded from environment variables""" - AWS_REGION = get_env_str("AWS_REGION", "us-east-1") - SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN") - SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET") - DYNAMODB_TABLE_NAME = get_env_str("DYNAMODB_TABLE_NAME", "gurumi-ai-bot-dev") - AGENT_ID = get_env_str("AGENT_ID", "None") - AGENT_ALIAS_ID = get_env_str("AGENT_ALIAS_ID", "None") - ALLOWED_CHANNEL_IDS = get_env_str("ALLOWED_CHANNEL_IDS", "None") - ALLOWED_CHANNEL_MESSAGE = get_env_str( - "ALLOWED_CHANNEL_MESSAGE", "Sorry, I'm not allowed to respond in this channel." - ) - PERSONAL_MESSAGE = get_env_str( - "PERSONAL_MESSAGE", "You are a friendly and professional AI assistant." - ) - SYSTEM_MESSAGE = get_env_str("SYSTEM_MESSAGE", "None") - MAX_LEN_SLACK = get_env_int("MAX_LEN_SLACK", 2000) - MAX_LEN_BEDROCK = get_env_int("MAX_LEN_BEDROCK", 4000) - MAX_THROTTLE_COUNT = get_env_int("MAX_THROTTLE_COUNT", 100) - SLACK_SAY_INTERVAL = get_env_float("SLACK_SAY_INTERVAL", 0) - BOT_CURSOR = get_env_str("BOT_CURSOR", ":robot_face:") - REACTION_EMOJIS = get_env_str("REACTION_EMOJIS", "refund-done") - - @classmethod - def get_reaction_emojis(cls) -> List[str]: - """Parse comma-separated reaction emojis into a list""" - if not cls.REACTION_EMOJIS or cls.REACTION_EMOJIS == "None": - return [] - return [emoji.strip() for emoji in cls.REACTION_EMOJIS.split(",") if emoji.strip()] - - @classmethod - def validate(cls) -> bool: - """Validate required configuration settings""" - required_vars = ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"] - missing = [var for var in required_vars if not getattr(cls, var)] - if missing: - print(f"Missing required environment variables: {', '.join(missing)}") - return False - return True - - -# Initialize AWS clients -dynamodb = boto3.resource("dynamodb") -table = dynamodb.Table(Config.DYNAMODB_TABLE_NAME) -bedrock_agent_client = boto3.client("bedrock-agent-runtime", region_name=Config.AWS_REGION) - -# Initialize Slack app -app = App( - token=Config.SLACK_BOT_TOKEN, - signing_secret=Config.SLACK_SIGNING_SECRET, - process_before_response=True, -) - -# Lazy initialization for bot_id to avoid API call at module load time -_bot_id: Optional[str] = None - - -def get_bot_id() -> str: - """Get Slack bot ID with lazy initialization""" - global _bot_id - if _bot_id is None: - _bot_id = app.client.api_call("auth.test")["user_id"] - return _bot_id - -# Status messages -MSG_PREVIOUS = f"이전 대화 내용 확인 중... {Config.BOT_CURSOR}" -MSG_RESPONSE = f"응답 기다리는 중... {Config.BOT_CURSOR}" -MSG_ERROR = f"오류가 발생했습니다. 잠시 후 다시 시도해주세요. {Config.BOT_CURSOR}" - - -class DynamoDBManager: - """Handles DynamoDB operations for conversation context""" - - @staticmethod - def get_context(thread_ts: Optional[str], user: str, default: str = "") -> str: - """Retrieve conversation context from DynamoDB""" - try: - key = {"id": thread_ts or user} - item = table.get_item(Key=key).get("Item") - return item["conversation"] if item else default - except Exception as e: - print(f"Error retrieving context: {e}") - return default - - @staticmethod - def put_context(thread_ts: Optional[str], user: str, conversation: str = "") -> None: - """Store conversation context in DynamoDB with TTL""" - try: - expire_at = int(time.time()) + 3600 # 1 hour TTL - expire_dt = datetime.fromtimestamp(expire_at).isoformat() - - item = { - "id": thread_ts or user, - "conversation": conversation, - "expire_dt": expire_dt, - "expire_at": expire_at, - } - - if thread_ts: - item["user"] = user - - table.put_item(Item=item) - except Exception as e: - print(f"Error storing context: {e}") - - @staticmethod - def count_user_contexts(user: str) -> int: - """Count contexts belonging to a specific user using GSI""" - try: - response = table.query( - IndexName="user-index", - KeyConditionExpression=Key("user").eq(user), - Select="COUNT" - ) - return response.get("Count", 0) - except Exception as e: - print(f"Error counting contexts: {e}") - return 0 - - -class MessageFormatter: - """Handles message formatting and splitting for Slack""" - - @staticmethod - def split_message(message: str, max_len: int) -> List[str]: - """Split a message into chunks that fit within max_len""" - # If message is empty or smaller than max_len, return as is - if not message or len(message) <= max_len: - return [message] - - # First split by code blocks - parts = [] - segments = message.split("```") - - for i, segment in enumerate(segments): - if not segment: # Skip empty segments - continue - - if i % 2 == 1: # This is a code block - # Preserve the code block formatting - code_parts = MessageFormatter._split_text(f"```{segment}```", max_len) - parts.extend(code_parts) - else: - # Regular text - split by paragraphs - text_parts = MessageFormatter._split_text(segment, max_len) - parts.extend(text_parts) - - # Final cleanup to ensure no part exceeds max_len - result = [] - current = "" - - for part in parts: - if len(current) + len(part) + 2 <= max_len: - if current: - current += "\n\n" + part - else: - current = part - else: - if current: - result.append(current) - current = part - - if current: - result.append(current) - - return result - - @staticmethod - def _split_text(text: str, max_len: int) -> List[str]: - """Helper method to split text by paragraphs""" - if len(text) <= max_len: - return [text] - - parts = text.split("\n\n") - result = [] - current = "" - - for part in parts: - # If a single part is longer than max_len, split it by sentences - if len(part) > max_len: - sentences = re.split(r'(?<=[.!?])\s+', part) - for sentence in sentences: - if len(current) + len(sentence) + 2 <= max_len: - if current: - current += " " + sentence - else: - current = sentence - else: - if current: - result.append(current) - current = sentence - elif len(current) + len(part) + 2 <= max_len: - if current: - current += "\n\n" + part - else: - current = part - else: - if current: - result.append(current) - current = part - - if current: - result.append(current) - - return result - - -class SlackManager: - """Handles Slack messaging operations""" - - # Cache for user display names to avoid repeated API calls - _user_name_cache: Dict[str, str] = {} - - @classmethod - def get_user_display_name(cls, user_id: str) -> str: - """Get user display name from Slack API with caching""" - if user_id in cls._user_name_cache: - return cls._user_name_cache[user_id] - - try: - response = app.client.users_info(user=user_id) - if response.get("ok"): - user_info = response.get("user", {}) - profile = user_info.get("profile", {}) - # Prefer display_name, fall back to real_name, then user_id - display_name = ( - profile.get("display_name") - or profile.get("real_name") - or user_id - ) - cls._user_name_cache[user_id] = display_name - return display_name - except Exception as e: - print(f"Error fetching user info for {user_id}: {e}") - - return user_id - - @staticmethod - def update_message(say: Say, channel: str, thread_ts: Optional[str], - latest_ts: str, message: str) -> tuple: - """Update existing message and send additional messages if needed""" - try: - split_messages = MessageFormatter.split_message(message, Config.MAX_LEN_SLACK) - - for i, text in enumerate(split_messages): - if i == 0: - # Update the initial message - app.client.chat_update(channel=channel, ts=latest_ts, text=text) - else: - # Add delay if configured - if Config.SLACK_SAY_INTERVAL > 0: - time.sleep(Config.SLACK_SAY_INTERVAL) - - # Send additional messages in thread - result = say(text=text, thread_ts=thread_ts) - latest_ts = result["ts"] - - return message, latest_ts - except Exception as e: - print(f"Error updating message: {e}") - # Update with error message - app.client.chat_update(channel=channel, ts=latest_ts, text=MSG_ERROR) - return MSG_ERROR, latest_ts - - @classmethod - def get_thread_history(cls, channel: str, thread_ts: str, client_msg_id: str) -> List[str]: - """Retrieve conversation history from a Slack thread""" - contexts = [] - - try: - response = app.client.conversations_replies(channel=channel, ts=thread_ts) - - if not response.get("ok"): - print("Failed to retrieve thread messages") - return contexts - - messages = response.get("messages", []) - - # Slack API returns messages in chronological order (oldest first) - # Include all messages (including thread parent) - # Process from newest to oldest to prioritize recent context - thread_messages = messages.copy() - thread_messages.reverse() # Now newest first - - for message in thread_messages: - # Skip the current message being processed - if message.get("client_msg_id") == client_msg_id: - continue - - # Determine role and author (Slack mention format for users) - if message.get("bot_id"): - role = "assistant" - author = "assistant" - else: - role = "user" - user_id = message.get("user", "") - author = f"<@{user_id}>" if user_id else "unknown" - - contexts.append(f"{role}({author}): {message.get('text', '')}") - - # Check if we've reached the context length limit - context_text = "\n".join(contexts) - if len(context_text) > Config.MAX_LEN_BEDROCK: - contexts.pop(0) # Remove oldest (first added) message - break - - # Reverse back to chronological order for the prompt - contexts.reverse() - - except Exception as e: - print(f"Error retrieving thread history: {e}") - - return contexts - - -class BedrockManager: - """Handles Amazon Bedrock operations""" - - @staticmethod - def invoke_agent(prompt: str) -> str: - """Invoke Amazon Bedrock Agent with prompt and return response""" - try: - # Create a unique session ID - now = datetime.now() - session_id = str(int(now.timestamp() * 1000)) - - # Call Bedrock Agent - response = bedrock_agent_client.invoke_agent( - agentId=Config.AGENT_ID, - agentAliasId=Config.AGENT_ALIAS_ID, - sessionId=session_id, - inputText=prompt, - ) - - # Process streaming response - completion = "" - for event in response.get("completion"): - chunk = event["chunk"] - completion += chunk["bytes"].decode() - - return completion - - except Exception as e: - print(f"Error invoking Bedrock Agent: {e}") - return f"죄송합니다. 응답을 생성하는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요. (오류: {type(e).__name__})" - - @staticmethod - def create_prompt(say: Optional[Say], query: str, thread_ts: Optional[str] = None, - channel: Optional[str] = None, client_msg_id: Optional[str] = None, - latest_ts: Optional[str] = None, user_id: Optional[str] = None) -> str: - """Create a prompt for the AI model with context and query""" - prompts = [] - prompts.append(f"User: {Config.PERSONAL_MESSAGE}") - - if Config.SYSTEM_MESSAGE != "None": - prompts.append(Config.SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Add conversation history if in a thread - if thread_ts and say and channel and client_msg_id: - # Update status message (only when using placeholder message) - if latest_ts: - SlackManager.update_message(say, channel, thread_ts, latest_ts, MSG_PREVIOUS) - - # Get thread history - contexts = SlackManager.get_thread_history(channel, thread_ts, client_msg_id) - - if contexts: - prompts.append(" 에 정보가 제공 되면, 대화 기록을 참고하여 답변해 주세요.") - prompts.append("") - prompts.append("\n\n".join(contexts)) - prompts.append("") - - # Add the current query with user_id (Slack mention format) - prompts.append("") - if user_id: - prompts.append(f"\">") - else: - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - return "\n".join(prompts) - - except Exception as e: - print(f"Error creating prompt: {e}") - raise e - - -def conversation(say: Say, query: str, thread_ts: Optional[str] = None, - channel: Optional[str] = None, client_msg_id: Optional[str] = None, - user_id: Optional[str] = None) -> None: - """Main conversation handler that processes queries and returns AI responses""" - print(f"conversation: query: {query}, user_id: {user_id}") - - use_assistant_status = bool(thread_ts and channel) - latest_ts = None - - try: - if use_assistant_status: - # Set assistant thread status (shows animated indicator) - try: - app.client.assistant_threads_setStatus( - channel_id=channel, - thread_ts=thread_ts, - status="is thinking...", - ) - except Exception as e: - print(f"Error setting thread status: {e}") - use_assistant_status = False - - if not use_assistant_status: - # Fallback: send placeholder message - result = say(text=Config.BOT_CURSOR, thread_ts=thread_ts) - latest_ts = result["ts"] - - # Create prompt with context and query - prompt = BedrockManager.create_prompt( - say, query, thread_ts, channel, client_msg_id, latest_ts, user_id - ) - - if not use_assistant_status and latest_ts: - # Update status while waiting for response - SlackManager.update_message(say, channel, thread_ts, latest_ts, MSG_RESPONSE) - - # Get response from AI - message = BedrockManager.invoke_agent(prompt) - - if use_assistant_status: - # Send response directly (auto-clears assistant status) - split_messages = MessageFormatter.split_message(message, Config.MAX_LEN_SLACK) - for i, text in enumerate(split_messages): - if Config.SLACK_SAY_INTERVAL > 0 and i > 0: - time.sleep(Config.SLACK_SAY_INTERVAL) - say(text=text, thread_ts=thread_ts) - else: - # Update placeholder with final response - SlackManager.update_message(say, channel, thread_ts, latest_ts, message) - - except Exception as e: - print(f"Error in conversation handler: {e}") - try: - if use_assistant_status: - say(text=MSG_ERROR, thread_ts=thread_ts) - elif latest_ts: - SlackManager.update_message(say, channel, thread_ts, latest_ts, MSG_ERROR) - except Exception: - pass - - -@app.event("app_mention") -def handle_mention(body: Dict[str, Any], say: Say) -> None: - """Handle mentions of the bot in channels""" - print(f"handle_mention: {body}") - - event = body["event"] - thread_ts = event.get("thread_ts", event.get("ts")) - channel = event.get("channel") - client_msg_id = event.get("client_msg_id") - user_id = event.get("user") - - # Check if the channel is allowed - if Config.ALLOWED_CHANNEL_IDS != "None": - allowed_channel_ids = Config.ALLOWED_CHANNEL_IDS.split(",") - if channel not in allowed_channel_ids: - first_channel = f"<#{allowed_channel_ids[0]}>" - message = Config.ALLOWED_CHANNEL_MESSAGE.format(first_channel) - say(text=message, thread_ts=thread_ts) - print(f"handle_mention: {message}") - return - - # Extract query text (remove the bot mention) - prompt = re.sub(f"<@{get_bot_id()}>", "", event["text"]).strip() - - # Process the conversation - conversation(say, prompt, thread_ts, channel, client_msg_id, user_id) - - -@app.event("message") -def handle_message(body: Dict[str, Any], say: Say) -> None: - """Handle direct messages to the bot""" - print(f"handle_message: {body}") - - event = body["event"] - - # Ignore messages from bots (including this bot) - if event.get("bot_id"): - return - - channel = event["channel"] - client_msg_id = event["client_msg_id"] - user_id = event.get("user") - prompt = event["text"].strip() - - # Process the conversation (thread_ts=None for DMs) - conversation(say, prompt, None, channel, client_msg_id, user_id) - - -def mask_account_number(account: str) -> str: - """Mask account number, showing only first 4 and last 2 digits""" - # Remove any non-digit characters for processing - digits_only = re.sub(r'\D', '', account) - - if len(digits_only) <= 6: - # If too short, mask middle portion - return digits_only[:2] + '*' * (len(digits_only) - 2) - - # Show first 4 and last 2 digits - return digits_only[:4] + '*' * (len(digits_only) - 6) + digits_only[-2:] - - -def process_refund_done(channel: str, message_ts: str, user: str) -> None: - """Process refund-done emoji reaction: mask account number and add refund timestamp""" - print(f"process_refund_done: channel={channel}, message_ts={message_ts}, user={user}") - - try: - # Get the original message - result = app.client.conversations_history( - channel=channel, - latest=message_ts, - limit=1, - inclusive=True - ) - - if not result.get("ok") or not result.get("messages"): - print("Failed to retrieve message") - return - - message = result["messages"][0] - blocks = message.get("blocks", []) - - if not blocks: - print("No blocks found in message") - return - - # Check if this is a refund request message (has the header) - is_refund_message = False - for block in blocks: - if block.get("type") == "header": - header_text = block.get("text", {}).get("text", "") - if "환불 신청" in header_text: - is_refund_message = True - break - - if not is_refund_message: - print("Not a refund request message") - return - - # Process blocks to mask account number and add refund timestamp - updated_blocks = [] - refund_time_added = False - # Convert to KST (UTC+9) - kst = timezone(timedelta(hours=9)) - current_time = datetime.now(kst).strftime("%Y. %m. %d. %p %I:%M:%S").replace("AM", "오전").replace("PM", "오후") - - for block in blocks: - if block.get("type") == "section" and block.get("fields"): - new_fields = [] - for field in block["fields"]: - field_text = field.get("text", "") - - # Mask account number - if "*계좌번호:*" in field_text: - lines = field_text.split("\n") - if len(lines) >= 2: - account = lines[1] - masked = mask_account_number(account) - field = { - "type": "mrkdwn", - "text": f"*계좌번호:*\n{masked}" - } - - new_fields.append(field) - - # Add refund timestamp if not already present - has_refund_time = any("*환불일시:*" in f.get("text", "") for f in new_fields) - if not has_refund_time and not refund_time_added: - new_fields.append({ - "type": "mrkdwn", - "text": f"*환불일시:*\n{current_time}" - }) - refund_time_added = True - - block = dict(block) - block["fields"] = new_fields - - updated_blocks.append(block) - - # Update the message - app.client.chat_update( - channel=channel, - ts=message_ts, - blocks=updated_blocks, - text=message.get("text", "환불 신청이 처리되었습니다.") - ) - - print(f"Refund message updated successfully") - - except Exception as e: - print(f"Error processing refund done: {e}") - - -# Reaction handlers mapping: emoji name -> handler function -REACTION_HANDLERS = { - "refund-done": lambda channel, ts, user: process_refund_done(channel, ts, user), -} - - -@app.event("reaction_added") -def handle_reaction_added(body: Dict[str, Any]) -> None: - """Handle emoji reaction added events""" - print(f"handle_reaction_added: {body}") - - event = body["event"] - reaction = event.get("reaction", "") - user = event.get("user", "") - item = event.get("item", {}) - - # Check if this reaction is in the allowed list - allowed_emojis = Config.get_reaction_emojis() - if reaction not in allowed_emojis: - return - - # Get message details - item_type = item.get("type", "") - if item_type != "message": - return - - channel = item.get("channel", "") - message_ts = item.get("ts", "") - - if not channel or not message_ts: - return - - # Dispatch to the appropriate handler - handler = REACTION_HANDLERS.get(reaction) - if handler: - handler(channel, message_ts, user) - else: - print(f"No handler found for reaction: {reaction}") - - -def success(message: str = "") -> Dict[str, Any]: - """Return a success response for Lambda""" - return { - "statusCode": 200, - "headers": {"Content-type": "application/json"}, - "body": json.dumps({"status": "Success", "message": message}), - } - - - -def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: - """Main Lambda handler for Slack events""" - # Validate required configuration - if not Config.validate(): - return { - "statusCode": 500, - "headers": {"Content-type": "application/json"}, - "body": json.dumps({"status": "Error", "message": "Missing required configuration"}), - } - - # Parse request body - body = json.loads(event["body"]) - - # Handle Slack verification challenge - if "challenge" in body: - return { - "statusCode": 200, - "headers": {"Content-type": "application/json"}, - "body": json.dumps({"challenge": body["challenge"]}), - } - - print(f"lambda_handler: {body}") - - # Check for valid event structure - if "event" not in body: - print("lambda_handler: event not found") - return success() - - event_type = body["event"].get("type", "") - - # Handle reaction events directly (no client_msg_id, no deduplication needed) - if event_type == "reaction_added": - slack_handler = SlackRequestHandler(app=app) - return slack_handler.handle(event, context) - - # For message events, check client_msg_id - if "client_msg_id" not in body["event"]: - print("lambda_handler: client_msg_id not found") - return success() - - # Extract message identifiers - token = body["event"]["client_msg_id"] - user = body["event"]["user"] - - # Check for duplicate events (idempotency) - if DynamoDBManager.get_context(token, user) != "": - print("lambda_handler: duplicate event detected") - return success() - - # Check user throttling - count = DynamoDBManager.count_user_contexts(user) - if count >= Config.MAX_THROTTLE_COUNT: - print(f"lambda_handler: throttle limit reached: {count} >= {Config.MAX_THROTTLE_COUNT}") - return success() - - # Store context to prevent duplicate processing - DynamoDBManager.put_context(token, user, body["event"]["text"]) - - # Handle the Slack event - slack_handler = SlackRequestHandler(app=app) - return slack_handler.handle(event, context) - - diff --git a/localtest.py b/localtest.py new file mode 100644 index 0000000..ec8b09e --- /dev/null +++ b/localtest.py @@ -0,0 +1,202 @@ +"""Local CLI test script — runs SlackMentionAgent without a real Slack connection. + +Streams LLM output token-by-token and prints intermediate agent steps +(tool calls, their results, compose phase) to stderr so they stay out of +the streamed answer on stdout. + +Usage: + python localtest.py "질문 내용" + python localtest.py # interactive (stdin, Ctrl+D to submit) + python localtest.py --no-stream # wait for the full answer, then print + python localtest.py --quiet-steps # hide intermediate step indicators + +Environment: + Copy .env.example to .env.local and fill in values before running. + Minimum required: OPENAI_API_KEY (for OpenAI provider). +""" +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path +from typing import Any + + +logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s") + + +LOCAL_UPLOAD_DIR = Path("./.uploads") + + +class _StubSlackClient: + """Returns empty but structurally correct responses for every Slack call. + + `files_upload_v2` writes the received bytes to ./.uploads/ so you can + actually open generated images instead of discarding them. + """ + + def conversations_replies(self, **_): + return {"messages": []} + + def files_upload_v2(self, *, file=None, filename="generated.bin", **_): + if file is None: + return {"file": {"permalink": "", "title": filename}} + LOCAL_UPLOAD_DIR.mkdir(exist_ok=True) + ts = int(time.time() * 1000) + path = LOCAL_UPLOAD_DIR / f"{ts}-{filename}" + path.write_bytes(file) + resolved = path.resolve() + return {"file": {"permalink": resolved.as_uri(), "title": filename}} + + def users_info(self, **_): + return {"user": {"profile": {"display_name": "local-user"}}} + + +def _build_slack_client(token: str): + if token and not token.startswith("xoxb-your"): + try: + from slack_sdk import WebClient + + return WebClient(token=token) + except Exception: + pass + return _StubSlackClient() + + +def _make_on_step(quiet: bool): + if quiet: + return None + + def on_step(step: int, phase: str, detail: dict[str, Any]) -> None: + if phase == "tool_use": + tools = ", ".join(detail.get("tools") or []) + print(f"\n[step {step}] ▶ 도구 호출: {tools}", file=sys.stderr, flush=True) + elif phase == "tool_result": + mark = "✓" if detail.get("ok") else "✗" + tool = detail.get("tool") or "" + if detail.get("ok"): + print(f"[step {step}] {mark} {tool}", file=sys.stderr, flush=True) + else: + err = detail.get("error") or "" + print(f"[step {step}] {mark} {tool}: {err}", file=sys.stderr, flush=True) + elif phase == "compose": + hint = " (max_steps 도달)" if detail.get("max_steps_hit") else "" + print(f"[step {step}] ▶ 답변 작성 중...{hint}", file=sys.stderr, flush=True) + + return on_step + + +def main() -> None: + parser = argparse.ArgumentParser(description="Local agent test runner.") + parser.add_argument("question", nargs="*", help="Question text. If omitted, read from stdin.") + parser.add_argument("--no-stream", action="store_true", help="Disable streaming output; print the full answer at the end.") + parser.add_argument("--quiet-steps", action="store_true", help="Suppress intermediate step logs on stderr.") + args = parser.parse_args() + + stream_mode = not args.no_stream + + from src.agent import SlackMentionAgent + from src.config import Settings + from src.llm import get_llm + from src.tools import ToolContext, default_registry + + settings = Settings.from_env() + + if not settings.slack_bot_token or settings.slack_bot_token.startswith("xoxb-your"): + print("[경고] SLACK_BOT_TOKEN 미설정 — Slack 관련 도구는 빈 결과를 반환합니다.\n", file=sys.stderr) + if settings.llm_provider == "openai": + import os + + if not os.getenv("OPENAI_API_KEY"): + print("[오류] OPENAI_API_KEY가 설정되지 않았습니다. .env.local 을 확인하세요.", file=sys.stderr) + sys.exit(1) + elif settings.llm_provider == "xai": + if not settings.xai_api_key: + print("[오류] XAI_API_KEY가 설정되지 않았습니다. .env.local 을 확인하세요.", file=sys.stderr) + sys.exit(1) + + llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={"xai": settings.xai_api_key}, + ) + + slack_client = _build_slack_client(settings.slack_bot_token) + context = ToolContext( + slack_client=slack_client, + channel="local", + thread_ts="0", + event={}, + settings=settings, + llm=llm, + ) + + if args.question: + user_message = " ".join(args.question).strip() + else: + print("질문을 입력하세요 (Ctrl+D 로 종료):", file=sys.stderr) + try: + user_message = sys.stdin.read().strip() + except (EOFError, KeyboardInterrupt): + print(file=sys.stderr) + sys.exit(0) + + if not user_message: + print("[오류] 질문이 비어 있습니다.", file=sys.stderr) + sys.exit(1) + + print(f"\n▶ 질문: {user_message}", file=sys.stderr) + + on_stream = None + if stream_mode: + def on_stream(delta: str) -> None: # noqa: RUF013 + sys.stdout.write(delta) + sys.stdout.flush() + + on_step = _make_on_step(args.quiet_steps) + + agent = SlackMentionAgent( + llm=llm, + context=context, + registry=default_registry, + max_steps=settings.agent_max_steps, + response_language=settings.response_language, + system_message=settings.system_message, + on_stream=on_stream, + on_step=on_step, + max_output_tokens=settings.max_output_tokens, + ) + + try: + result = agent.run(user_message) + except Exception as exc: # noqa: BLE001 + print(f"\n[오류] {exc}", file=sys.stderr) + sys.exit(1) + + if stream_mode: + # Streaming already wrote the answer to stdout; just end the line. + sys.stdout.write("\n") + sys.stdout.flush() + else: + print("\n" + "─" * 60, file=sys.stderr) + sys.stdout.write(result.text + "\n") + sys.stdout.flush() + print("─" * 60, file=sys.stderr) + + if result.image_url: + print(f"[이미지] {result.image_url}", file=sys.stderr) + + print( + f"\nsteps={result.steps} tool_calls={result.tool_calls_count} " + f"tokens_in={result.token_usage.get('input', 0)} tokens_out={result.token_usage.get('output', 0)}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f531c31..0000000 --- a/package-lock.json +++ /dev/null @@ -1,7814 +0,0 @@ -{ - "name": "lambda-gurumi-ai-bot", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lambda-gurumi-ai-bot", - "devDependencies": { - "serverless-dotenv-plugin": "^6.0.0", - "serverless-python-requirements": "^6.1.2" - } - }, - "node_modules/@aws-crypto/ie11-detection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", - "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", - "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/ie11-detection": "^3.0.0", - "@aws-crypto/sha256-js": "^3.0.0", - "@aws-crypto/supports-web-crypto": "^3.0.0", - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", - "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", - "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-crypto/util": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", - "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/@aws-crypto/util/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "peer": true - }, - "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.588.0.tgz", - "integrity": "sha512-iJs8mCtaPVeeyYxwIVyaesZh7a6FRJezxoN87v5g0AzjxLJshb07lTKxJFtdzhJmAYM8RfKIayaB33tlNNGsRQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sso-oidc": "3.588.0", - "@aws-sdk/client-sts": "3.588.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/credential-provider-node": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "@smithy/util-waiter": "^3.0.0", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.588.0.tgz", - "integrity": "sha512-zKS+xUkBLfwjbh77ZjtRUoG/vR/fyDteSE6rOAzwlmHQL8p+QUX+zNUNvCInvPi62zGBhEwXOvzs8zvnT4NzfQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.588.0.tgz", - "integrity": "sha512-CTbgtLSg0y2jIOtESuQKkRIqRe/FQmKuyzFWc+Qy6yGcbk1Pyusfz2BC+GGwpYU+1BlBBSNnLQHpx3XY87+aSA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.588.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/credential-provider-node": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sts": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.588.0.tgz", - "integrity": "sha512-UIMjcUikgG9NIENQxSyJNTHMD8TaTfK6Jjf1iuZSyQRyTrcGy0/xcDxrmwZQFAPkOPUf6w9KqydLkMLcYOBdPQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sso-oidc": "3.588.0", - "@aws-sdk/core": "3.588.0", - "@aws-sdk/credential-provider-node": "3.588.0", - "@aws-sdk/middleware-host-header": "3.577.0", - "@aws-sdk/middleware-logger": "3.577.0", - "@aws-sdk/middleware-recursion-detection": "3.577.0", - "@aws-sdk/middleware-user-agent": "3.587.0", - "@aws-sdk/region-config-resolver": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@aws-sdk/util-user-agent-browser": "3.577.0", - "@aws-sdk/util-user-agent-node": "3.587.0", - "@smithy/config-resolver": "^3.0.1", - "@smithy/core": "^2.1.1", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/hash-node": "^3.0.0", - "@smithy/invalid-dependency": "^3.0.0", - "@smithy/middleware-content-length": "^3.0.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-body-length-browser": "^3.0.0", - "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.3", - "@smithy/util-defaults-mode-node": "^3.0.3", - "@smithy/util-endpoints": "^2.0.1", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.588.0.tgz", - "integrity": "sha512-O1c2+9ce46Z+iiid+W3iC1IvPbfIo5ev9CBi54GdNB9SaI8/3+f8MJcux0D6c9toCF0ArMersN/gp8ek57e9uQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/core": "^2.1.1", - "@smithy/protocol-http": "^4.0.0", - "@smithy/signature-v4": "^3.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "fast-xml-parser": "4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.587.0.tgz", - "integrity": "sha512-Hyg/5KFECIk2k5o8wnVEiniV86yVkhn5kzITUydmNGCkXdBFHMHRx6hleQ1bqwJHbBskyu8nbYamzcwymmGwmw==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.587.0.tgz", - "integrity": "sha512-Su1SRWVRCuR1e32oxX3C1V4c5hpPN20WYcRfdcr2wXwHqSvys5DrnmuCC+JoEnS/zt3adUJhPliTqpfKgSdMrA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/util-stream": "^3.0.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.588.0.tgz", - "integrity": "sha512-tP/YmEKvYpmp7pCR2OuhoOhAOtm6BbZ1hbeG9Sw9RFZi55dbGPHqMmfvvzHFAGsJ20z4/oDS+UnHaWVhRnV82w==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/credential-provider-env": "3.587.0", - "@aws-sdk/credential-provider-http": "3.587.0", - "@aws-sdk/credential-provider-process": "3.587.0", - "@aws-sdk/credential-provider-sso": "3.588.0", - "@aws-sdk/credential-provider-web-identity": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/credential-provider-imds": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.588.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.588.0.tgz", - "integrity": "sha512-8s4Ruo6q1YIrj8AZKBiUQG42051ytochDMSqdVOEZGxskfvmt2XALyi5SsWd0Ve3zR95zi+EtRBNPn2EU8sQpA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/credential-provider-env": "3.587.0", - "@aws-sdk/credential-provider-http": "3.587.0", - "@aws-sdk/credential-provider-ini": "3.588.0", - "@aws-sdk/credential-provider-process": "3.587.0", - "@aws-sdk/credential-provider-sso": "3.588.0", - "@aws-sdk/credential-provider-web-identity": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/credential-provider-imds": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.587.0.tgz", - "integrity": "sha512-V4xT3iCqkF8uL6QC4gqBJg/2asd/damswP1h9HCfqTllmPWzImS+8WD3VjgTLw5b0KbTy+ZdUhKc0wDnyzkzxg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.588.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.588.0.tgz", - "integrity": "sha512-1GstMCyFzenVeppK7hWazMvo3P1DXKP70XkXAjH8H2ELBVg5X8Zt043cnQ7CMt4XjCV+ettHAtc9kz/gJTkDNQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/client-sso": "3.588.0", - "@aws-sdk/token-providers": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.587.0.tgz", - "integrity": "sha512-XqIx/I2PG7kyuw3WjAP9wKlxy8IvFJwB8asOFT1xPFoVfZYKIogjG9oLP5YiRtfvDkWIztHmg5MlVv3HdJDGRw==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sts": "^3.587.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.577.0.tgz", - "integrity": "sha512-9ca5MJz455CODIVXs0/sWmJm7t3QO4EUa1zf8pE8grLpzf0J94bz/skDWm37Pli13T3WaAQBHCTiH2gUVfCsWg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.577.0.tgz", - "integrity": "sha512-aPFGpGjTZcJYk+24bg7jT4XdIp42mFXSuPt49lw5KygefLyJM/sB0bKKqPYYivW0rcuZ9brQ58eZUNthrzYAvg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.577.0.tgz", - "integrity": "sha512-pn3ZVEd2iobKJlR3H+bDilHjgRnNrQ6HMmK9ZzZw89Ckn3Dcbv48xOv4RJvu0aU8SDLl/SNCxppKjeLDTPGBNA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.587.0.tgz", - "integrity": "sha512-SyDomN+IOrygLucziG7/nOHkjUXES5oH5T7p8AboO8oakMQJdnudNXiYWTicQWO52R51U6CR27rcMPTGeMedYA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-endpoints": "3.587.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.587.0.tgz", - "integrity": "sha512-93I7IPZtulZQoRK+O20IJ4a1syWwYPzoO2gc3v+/GNZflZPV3QJXuVbIm0pxBsu0n/mzKGUKqSOLPIaN098HcQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.587.0.tgz", - "integrity": "sha512-ULqhbnLy1hmJNRcukANBWJmum3BbjXnurLPSFXoGdV0llXYlG55SzIla2VYqdveQEEjmsBuTZdFvXAtNpmS5Zg==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-sso-oidc": "^3.587.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.577.0.tgz", - "integrity": "sha512-FT2JZES3wBKN/alfmhlo+3ZOq/XJ0C7QOZcDNrpKjB0kqYoKjhVKZ/Hx6ArR0czkKfHzBBEs6y40ebIHx2nSmA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.587.0.tgz", - "integrity": "sha512-8I1HG6Em8wQWqKcRW6m358mqebRVNpL8XrrEoT4In7xqkKkmYtHRNVYP6lcmiQh5pZ/c/FXu8dSchuFIWyEtqQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/types": "^3.0.0", - "@smithy/util-endpoints": "^2.0.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.568.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", - "integrity": "sha512-3nh4TINkXYr+H41QaPelCceEB2FXP3fxp93YZXB/kqJvX0U9j0N0Uk45gvsjmEPzG8XxkPEeLIfT2I1M7A6Lig==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.577.0.tgz", - "integrity": "sha512-zEAzHgR6HWpZOH7xFgeJLc6/CzMcx4nxeQolZxVZoB5pPaJd3CjyRhZN0xXeZB0XIRCWmb4yJBgyiugXLNMkLA==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/types": "^3.0.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.587.0.tgz", - "integrity": "sha512-Pnl+DUe/bvnbEEDHP3iVJrOtE3HbFJBPgsD6vJ+ml/+IYk1Eq49jEG+EHZdNTPz3SDG0kbp2+7u41MKYJHR/iQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.259.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", - "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.3.1" - } - }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "dev": true - }, - "node_modules/@kwsites/file-exists": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "^4.1.1" - } - }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "dev": true, - "peer": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "peer": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "peer": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@serverless/dashboard-plugin": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/@serverless/dashboard-plugin/-/dashboard-plugin-7.2.3.tgz", - "integrity": "sha512-Vu4TKJLEQ5F8ZipfCvd8A/LMIdH8kNGe448sX9mT4/Z0JVUaYmMc3BwkQ+zkNIh3QdBKAhocGn45TYjHV6uPWQ==", - "dev": true, - "peer": true, - "dependencies": { - "@aws-sdk/client-cloudformation": "^3.410.0", - "@aws-sdk/client-sts": "^3.410.0", - "@serverless/event-mocks": "^1.1.1", - "@serverless/platform-client": "^4.5.1", - "@serverless/utils": "^6.14.0", - "child-process-ext": "^3.0.1", - "chokidar": "^3.5.3", - "flat": "^5.0.2", - "fs-extra": "^9.1.0", - "js-yaml": "^4.1.0", - "jszip": "^3.10.1", - "lodash": "^4.17.21", - "memoizee": "^0.4.15", - "ncjsm": "^4.3.2", - "node-dir": "^0.1.17", - "node-fetch": "^2.6.8", - "open": "^7.4.2", - "semver": "^7.3.8", - "simple-git": "^3.16.0", - "timers-ext": "^0.1.7", - "type": "^2.7.2", - "uuid": "^8.3.2", - "yamljs": "^0.3.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/child-process-ext": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/child-process-ext/-/child-process-ext-3.0.2.tgz", - "integrity": "sha512-oBePsLbQpTJFxzwyCvs9yWWF0OEM6vGGepHwt1stqmX7QQqOuDc8j2ywdvAs9Tvi44TT7d9ackqhR4Q10l1u8w==", - "dev": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "es5-ext": "^0.10.62", - "log": "^6.3.1", - "split2": "^3.2.2", - "stream-promise": "^3.2.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "peer": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "peer": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "dev": true, - "peer": true, - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "peer": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@serverless/dashboard-plugin/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "peer": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@serverless/event-mocks": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@serverless/event-mocks/-/event-mocks-1.1.1.tgz", - "integrity": "sha512-YAV5V/y+XIOfd+HEVeXfPWZb8C6QLruFk9tBivoX2roQLWVq145s4uxf8D0QioCueuRzkukHUS4JIj+KVoS34A==", - "dev": true, - "peer": true, - "dependencies": { - "@types/lodash": "^4.14.123", - "lodash": "^4.17.11" - } - }, - "node_modules/@serverless/platform-client": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@serverless/platform-client/-/platform-client-4.5.1.tgz", - "integrity": "sha512-XltmO/029X76zi0LUFmhsnanhE2wnqH1xf+WBt5K8gumQA9LnrfwLgPxj+VA+mm6wQhy+PCp7H5SS0ZPu7F2Cw==", - "dev": true, - "peer": true, - "dependencies": { - "adm-zip": "^0.5.5", - "archiver": "^5.3.0", - "axios": "^1.6.2", - "fast-glob": "^3.2.7", - "https-proxy-agent": "^5.0.0", - "ignore": "^5.1.8", - "isomorphic-ws": "^4.0.1", - "js-yaml": "^3.14.1", - "jwt-decode": "^2.2.0", - "minimatch": "^3.0.4", - "querystring": "^0.2.1", - "run-parallel-limit": "^1.1.0", - "throat": "^5.0.0", - "traverse": "^0.6.6", - "ws": "^7.5.3" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/@serverless/platform-client/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@serverless/platform-client/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@serverless/utils": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@serverless/utils/-/utils-6.15.0.tgz", - "integrity": "sha512-7eDbqKv/OBd11jjdZjUwFGN8sHWkeUqLeHXHQxQ1azja2IM7WIH+z/aLgzR6LhB3/MINNwtjesDpjGqTMj2JKQ==", - "dev": true, - "peer": true, - "dependencies": { - "archive-type": "^4.0.0", - "chalk": "^4.1.2", - "ci-info": "^3.8.0", - "cli-progress-footer": "^2.3.2", - "content-disposition": "^0.5.4", - "d": "^1.0.1", - "decompress": "^4.2.1", - "event-emitter": "^0.3.5", - "ext": "^1.7.0", - "ext-name": "^5.0.0", - "file-type": "^16.5.4", - "filenamify": "^4.3.0", - "get-stream": "^6.0.1", - "got": "^11.8.6", - "inquirer": "^8.2.5", - "js-yaml": "^4.1.0", - "jwt-decode": "^3.1.2", - "lodash": "^4.17.21", - "log": "^6.3.1", - "log-node": "^8.0.3", - "make-dir": "^4.0.0", - "memoizee": "^0.4.15", - "ms": "^2.1.3", - "ncjsm": "^4.3.2", - "node-fetch": "^2.6.11", - "open": "^8.4.2", - "p-event": "^4.2.0", - "supports-color": "^8.1.1", - "timers-ext": "^0.1.7", - "type": "^2.7.2", - "uni-global": "^1.0.0", - "uuid": "^8.3.2", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/@serverless/utils/node_modules/jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", - "dev": true, - "peer": true - }, - "node_modules/@serverless/utils/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@smithy/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-p6GlFGBt9K4MYLu72YuJ523NVR4A8oHlC5M2JO6OmQqN8kAc/uh1JqLE+FizTokrSJGg0CSvC+BrsmGzKtsZKA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.1.tgz", - "integrity": "sha512-hbkYJc20SBDz2qqLzttjI/EqXemtmWk0ooRznLsiXp3066KQRTvuKHa7U4jCZCJq6Dozqvy0R1/vNESC9inPJg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.1.1.tgz", - "integrity": "sha512-0vbIwwUcg0FMhTVJgMhbsRSAFL0rwduy/OQz7Xq1pJXJOyaGv+PGjj1iGawRlzBUPA5BkJv7S6q+YU2U8gk/WA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-retry": "^3.0.3", - "@smithy/middleware-serde": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz", - "integrity": "sha512-q4A4d38v8pYYmseu/jTS3Z5I3zXlEOe5Obi+EJreVKgSVyWUHOd7/yaVCinC60QG4MRyCs98tcxBH1IMC0bu7Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.0.1.tgz", - "integrity": "sha512-uaH74i5BDj+rBwoQaXioKpI0SHBJFtOVwzrCpxZxphOW0ki5jhj7dXvDMYM2IJem8TpdFvS2iC08sjOblfFGFg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/protocol-http": "^4.0.0", - "@smithy/querystring-builder": "^3.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/hash-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.0.tgz", - "integrity": "sha512-84qXstNemP3XS5jcof0el6+bDfjzuvhJPQTEfro3lgtbCtKgzPm3MgiS6ehXVPjeQ5+JS0HqmTz8f/RYfzHVxw==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "@smithy/util-buffer-from": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.0.tgz", - "integrity": "sha512-F6wBBaEFgJzj0s4KUlliIGPmqXemwP6EavgvDqYwCH40O5Xr2iMHvS8todmGVZtuJCorBkXsYLyTu4PuizVq5g==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", - "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.0.tgz", - "integrity": "sha512-3C4s4d/iGobgCtk2tnWW6+zSTOBg1PRAm2vtWZLdriwTroFbbWNSr3lcyzHdrQHnEXYCC5K52EbpfodaIUY8sg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.0.1.tgz", - "integrity": "sha512-lQ/UOdGD4KM5kLZiAl0q8Qy3dPbynvAXKAdXnYlrA1OpaUwr+neSsVokDZpY6ZVb5Yx8jnus29uv6XWpM9P4SQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/middleware-serde": "^3.0.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "@smithy/url-parser": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.3.tgz", - "integrity": "sha512-Wve1qzJb83VEU/6q+/I0cQdAkDnuzELC6IvIBwDzUEiGpKqXgX1v10FUuZGbRS6Ov/P+HHthcAoHOJZQvZNAkA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/service-error-classification": "^3.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-retry": "^3.0.0", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.0.tgz", - "integrity": "sha512-I1vKG1foI+oPgG9r7IMY1S+xBnmAn1ISqployvqkwHoSb8VPsngHDTOgYGYBonuOKndaWRUGJZrKYYLB+Ane6w==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.0.tgz", - "integrity": "sha512-+H0jmyfAyHRFXm6wunskuNAqtj7yfmwFB6Fp37enytp2q047/Od9xetEaUbluyImOlGnGpaVGaVfjwawSr+i6Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.0.tgz", - "integrity": "sha512-ngfB8QItUfTFTfHMvKuc2g1W60V1urIgZHqD1JNFZC2tTWXahqf2XvKXqcBS7yZqR7GqkQQZy11y/lNOUWzq7Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/property-provider": "^3.1.0", - "@smithy/shared-ini-file-loader": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.0.0.tgz", - "integrity": "sha512-3trD4r7NOMygwLbUJo4eodyQuypAWr7uvPnebNJ9a70dQhVn+US8j/lCnvoJS6BXfZeF7PkkkI0DemVJw+n+eQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/abort-controller": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/querystring-builder": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.0.tgz", - "integrity": "sha512-Tj3+oVhqdZgemjCiWjFlADfhvLF4C/uKDuKo7/tlEsRQ9+3emCreR2xndj970QSRSsiCEU8hZW3/8JQu+n5w4Q==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.0.0.tgz", - "integrity": "sha512-qOQZOEI2XLWRWBO9AgIYuHuqjZ2csyr8/IlgFDHDNuIgLAMRx2Bl8ck5U5D6Vh9DPdoaVpuzwWMa0xcdL4O/AQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.0.tgz", - "integrity": "sha512-bW8Fi0NzyfkE0TmQphDXr1AmBDbK01cA4C1Z7ggwMAU5RDz5AAv/KmoRwzQAS0kxXNf/D2ALTEgwK0U2c4LtRg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "@smithy/util-uri-escape": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.0.tgz", - "integrity": "sha512-UzHwthk0UEccV4dHzPySnBy34AWw3V9lIqUTxmozQ+wPDAO9csCWMfOLe7V9A2agNYy7xE+Pb0S6K/J23JSzfQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.0.tgz", - "integrity": "sha512-3BsBtOUt2Gsnc3X23ew+r2M71WwtpHfEDGhHYHSDg6q1t8FrWh15jT25DLajFV1H+PpxAJ6gqe9yYeRUsmSdFA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.0.tgz", - "integrity": "sha512-dAM7wSX0NR3qTNyGVN/nwwpEDzfV9T/3AN2eABExWmda5VqZKSsjlINqomO5hjQWGv+IIkoXfs3u2vGSNz8+Rg==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-3.0.0.tgz", - "integrity": "sha512-kXFOkNX+BQHe2qnLxpMEaCRGap9J6tUGLzc3A9jdn+nD4JdMwCKTJ+zFwQ20GkY+mAXGatyTw3HcoUlR39HwmA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", - "@smithy/util-uri-escape": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.1.1.tgz", - "integrity": "sha512-tj4Ku7MpzZR8cmVuPcSbrLFVxmptWktmJMwST/uIEq4sarabEdF8CbmQdYB7uJ/X51Qq2EYwnRsoS7hdR4B7rA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/middleware-stack": "^3.0.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-stream": "^3.0.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.0.0.tgz", - "integrity": "sha512-VvWuQk2RKFuOr98gFhjca7fkBS+xLLURT8bUjk5XQoV0ZLm7WPwWPPY3/AwzTLuUBDeoKDCthfe1AsTUWaSEhw==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.0.tgz", - "integrity": "sha512-2XLazFgUu+YOGHtWihB3FSLAfCUajVfNBXGGYjOaVKjLAuAxx3pSBY3hBgLzIgB17haf59gOG3imKqTy8mcrjw==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/querystring-parser": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/util-base64": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", - "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz", - "integrity": "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz", - "integrity": "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", - "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz", - "integrity": "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.3.tgz", - "integrity": "sha512-3DFON2bvXJAukJe+qFgPV/rorG7ZD3m4gjCXHD1V5z/tgKQp5MCTCLntrd686tX6tj8Uli3lefWXJudNg5WmCA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/property-provider": "^3.1.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.3.tgz", - "integrity": "sha512-D0b8GJXecT00baoSQ3Iieu3k3mZ7GY8w1zmg8pdogYrGvWJeLcIclqk2gbkG4K0DaBGWrO6v6r20iwIFfDYrmA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/config-resolver": "^3.0.1", - "@smithy/credential-provider-imds": "^3.1.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/property-provider": "^3.1.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.0.1.tgz", - "integrity": "sha512-ZRT0VCOnKlVohfoABMc8lWeQo/JEFuPWctfNRXgTHbyOVssMOLYFUNWukxxiHRGVAhV+n3c0kPW+zUqckjVPEA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/node-config-provider": "^3.1.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", - "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.0.tgz", - "integrity": "sha512-q5ITdOnV2pXHSVDnKWrwgSNTDBAMHLptFE07ua/5Ty5WJ11bvr0vk2a7agu7qRhrCFRQlno5u3CneU5EELK+DQ==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.0.tgz", - "integrity": "sha512-nK99bvJiziGv/UOKJlDvFF45F00WgPLKVIGUfAK+mDhzVN2hb/S33uW2Tlhg5PVBoqY7tDVqL0zmu4OxAHgo9g==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/service-error-classification": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.0.1.tgz", - "integrity": "sha512-7F7VNNhAsfMRA8I986YdOY5fE0/T1/ZjFF6OLsqkvQVNP3vZ/szYDfGCyphb7ioA09r32K/0qbSFfNFU68aSzA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/fetch-http-handler": "^3.0.1", - "@smithy/node-http-handler": "^3.0.0", - "@smithy/types": "^3.0.0", - "@smithy/util-base64": "^3.0.0", - "@smithy/util-buffer-from": "^3.0.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", - "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", - "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/util-buffer-from": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.0.0.tgz", - "integrity": "sha512-+fEXJxGDLCoqRKVSmo0auGxaqbiCo+8oph+4auefYjaNxjOLKSY2MxVQfRzo65PaZv4fr+5lWg+au7vSuJJ/zw==", - "dev": true, - "peer": true, - "dependencies": { - "@smithy/abort-controller": "^3.0.0", - "@smithy/types": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "peer": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "dev": true, - "peer": true - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "peer": true, - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, - "peer": true - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.4", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==", - "dev": true, - "peer": true - }, - "node_modules/@types/node": { - "version": "20.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz", - "integrity": "sha512-T2MzSGEu+ysB/FkWfqmhV3PLyQlowdptmmgD20C6QxsS8Fmv5SjpZ1ayXaEC0S21/h5UJ9iA6W/5vSNU5l00OA==", - "dev": true, - "peer": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/2-thenable": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/2-thenable/-/2-thenable-1.0.0.tgz", - "integrity": "sha512-HqiDzaLDFCXkcCO/SwoyhRwqYtINFHF7t9BDRq4x90TOKNAJpiqUt9X5lQ08bwxYzc067HUywDjGySpebHcUpw==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.47" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "peer": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/adm-zip": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.13.tgz", - "integrity": "sha512-4U51tTl9J8UVEcuKGr6zRzY95tWoAa9l+ureGBNmsfleszjZblm5NyEEL/ZQxkhi86co5mZhSvL2T7gkZ6feYQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.15.0.tgz", - "integrity": "sha512-15BTtQUOsSrmHCy+B4VnAiJAJxJ8IFgu6fcjFQF3jQYZ78nLSQthlFg4ehp+NLIyfvFgOlxNsjKIEhydtFPVHQ==", - "dev": true, - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.3.0", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "peer": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "peer": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/appdirectory": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/appdirectory/-/appdirectory-0.1.0.tgz", - "integrity": "sha512-DJ5DV8vZXBbusyiyPlH28xppwS8eAMRuuyMo88xeEcf4bV64lbLtbxRxqixZuJBXsZzLtXFmA13GwVjJc7vdQw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true - }, - "node_modules/archive-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz", - "integrity": "sha512-zV4Ky0v1F8dBrdYElwTvQhweQ0P7Kwc1aluqJsYtOBP01jXcWCyW2IEfI1YiqsG+Iy7ZR+o5LF1N+PGECBxHWA==", - "dev": true, - "peer": true, - "dependencies": { - "file-type": "^4.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/archive-type/node_modules/file-type": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", - "integrity": "sha512-f2UbFQEk7LXgWpi5ntcO86OeA/cC80fuDDDaX/fZ2ZGel+AF7leRQqBBW1eJNiiQkrZlAoM6P+VYP5P6bOlDEQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", - "dev": true, - "peer": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", - "dev": true, - "peer": true, - "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", - "dev": true, - "peer": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "peer": true - }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true, - "peer": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "peer": true - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "peer": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/aws-sdk": { - "version": "2.1633.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1633.0.tgz", - "integrity": "sha512-9OUTdosQgZO6P6hPyEOUDvm4cymbMB0Qn1etucPql95DHjNyIex3FJOBbeRikU+5Wt9Djgdcr+BqAee2Lv/7iw==", - "dev": true, - "hasInstallScript": true, - "peer": true, - "dependencies": { - "buffer": "4.9.2", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.16.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "util": "^0.12.4", - "uuid": "8.0.0", - "xml2js": "0.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-sdk/node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/aws-sdk/node_modules/uuid": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", - "dev": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", - "dev": true, - "peer": true, - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "peer": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, - "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", - "dev": true, - "peer": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "peer": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, - "peer": true, - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "peer": true, - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true, - "peer": true - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "dev": true, - "peer": true - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/builtins": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", - "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", - "dev": true, - "peer": true - }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "peer": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "peer": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cachedir": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, - "peer": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "peer": true - }, - "node_modules/child-process-ext": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/child-process-ext/-/child-process-ext-2.1.1.tgz", - "integrity": "sha512-0UQ55f51JBkOFa+fvR76ywRzxiPwQS3Xe8oe5bZRphpv+dIMeerW5Zn5e4cUy4COJwVtJyU0R79RMnw+aCqmGA==", - "dev": true, - "dependencies": { - "cross-spawn": "^6.0.5", - "es5-ext": "^0.10.53", - "log": "^6.0.0", - "split2": "^3.1.1", - "stream-promise": "^3.2.0" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-color": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.4.tgz", - "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.64", - "es6-iterator": "^2.0.3", - "memoizee": "^0.4.15", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "peer": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-progress-footer": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/cli-progress-footer/-/cli-progress-footer-2.3.3.tgz", - "integrity": "sha512-p+hyTPxSZWG1c3Qy1DLBoGZhpeA3Y6AMlKrtbGpMMSKpezbSLel8gW4e5You4FNlHb3wS/M1JU594OAWe/Totg==", - "dev": true, - "peer": true, - "dependencies": { - "cli-color": "^2.0.4", - "d": "^1.0.1", - "es5-ext": "^0.10.64", - "mute-stream": "0.0.8", - "process-utils": "^4.0.0", - "timers-ext": "^0.1.7", - "type": "^2.7.2" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-sprintf-format": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cli-sprintf-format/-/cli-sprintf-format-1.1.1.tgz", - "integrity": "sha512-BbEjY9BEdA6wagVwTqPvmAwGB24U93rQPBFZUT8lNCDxXzre5LFHQUTJc70czjgUomVg8u8R5kW8oY9DYRFNeg==", - "dev": true, - "peer": true, - "dependencies": { - "cli-color": "^2.0.1", - "es5-ext": "^0.10.53", - "sprintf-kit": "^2.0.1", - "supports-color": "^6.1.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/cli-sprintf-format/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/cli-sprintf-format/node_modules/supports-color": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", - "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "peer": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "peer": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "peer": true - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, - "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - }, - "engines": { - "node": ">=4.8" - } - }, - "node_modules/cross-spawn/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dev": true, - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dayjs": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", - "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==", - "dev": true, - "peer": true - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, - "peer": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "peer": true - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", - "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", - "dev": true, - "peer": true, - "dependencies": { - "decompress-tar": "^4.0.0", - "decompress-tarbz2": "^4.0.0", - "decompress-targz": "^4.0.0", - "decompress-unzip": "^4.0.1", - "graceful-fs": "^4.1.10", - "make-dir": "^1.0.0", - "pify": "^2.3.0", - "strip-dirs": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "peer": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-tar": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", - "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", - "dev": true, - "peer": true, - "dependencies": { - "file-type": "^5.2.0", - "is-stream": "^1.1.0", - "tar-stream": "^1.5.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar/node_modules/bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", - "dev": true, - "peer": true, - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/decompress-tar/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/decompress-tar/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "peer": true - }, - "node_modules/decompress-tar/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/decompress-tar/node_modules/tar-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", - "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", - "dev": true, - "peer": true, - "dependencies": { - "bl": "^1.0.0", - "buffer-alloc": "^1.2.0", - "end-of-stream": "^1.0.0", - "fs-constants": "^1.0.0", - "readable-stream": "^2.3.0", - "to-buffer": "^1.1.1", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/decompress-tarbz2": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", - "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", - "dev": true, - "peer": true, - "dependencies": { - "decompress-tar": "^4.1.0", - "file-type": "^6.1.0", - "is-stream": "^1.1.0", - "seek-bzip": "^1.0.5", - "unbzip2-stream": "^1.0.9" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tarbz2/node_modules/file-type": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", - "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", - "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", - "dev": true, - "peer": true, - "dependencies": { - "decompress-tar": "^4.1.1", - "file-type": "^5.2.0", - "is-stream": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", - "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", - "dev": true, - "peer": true, - "dependencies": { - "file-type": "^3.8.0", - "get-stream": "^2.2.0", - "pify": "^2.3.0", - "yauzl": "^2.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip/node_modules/file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress-unzip/node_modules/get-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", - "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", - "dev": true, - "peer": true, - "dependencies": { - "object-assign": "^4.0.1", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress/node_modules/make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "peer": true, - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress/node_modules/make-dir/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "peer": true, - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/deferred": { - "version": "0.7.11", - "resolved": "https://registry.npmjs.org/deferred/-/deferred-0.7.11.tgz", - "integrity": "sha512-8eluCl/Blx4YOGwMapBvXRKxHXhA8ejDXYzEaK8+/gtcm8hRMhSLmXSqDmNUKNc/C8HNSmuyyp/hflhqDAvK2A==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.50", - "event-emitter": "^0.3.5", - "next-tick": "^1.0.0", - "timers-ext": "^0.1.7" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "peer": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "peer": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "peer": true, - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "peer": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/duration": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/duration/-/duration-0.2.2.tgz", - "integrity": "sha512-06kgtea+bGreF5eKYgI/36A6pLXggY7oR4p1pq4SmdFBn1ReOL5D8RhG64VrqfTTKNucqqtBAwEj8aB88mcqrg==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "~0.10.46" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "peer": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", - "dev": true, - "peer": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "peer": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, - "peer": true, - "dependencies": { - "get-intrinsic": "^1.2.4", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "peer": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-set": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.6.tgz", - "integrity": "sha512-TE3LgGLDIBX332jq3ypv6bcOpkLO0AslAQo7p2VqX/1N46YNsvIWgvjojjSEnWEGWMhr1qUbYeTSir5J6mFHOw==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "es6-iterator": "~2.0.3", - "es6-symbol": "^3.1.3", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dev": true, - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dev": true, - "peer": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dev": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "peer": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/essentials": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/essentials/-/essentials-1.2.0.tgz", - "integrity": "sha512-kP/j7Iw7KeNE8b/o7+tr9uX2s1wegElGOoGZ2Xm35qBr4BbbEcH3/bxR2nfH9l9JANCq9AUrvKw+gRuHtZp0HQ==", - "dev": true, - "peer": true, - "dependencies": { - "uni-global": "^1.0.0" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dev": true, - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dev": true, - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/ext-list": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", - "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", - "dev": true, - "peer": true, - "dependencies": { - "mime-db": "^1.28.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ext-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", - "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", - "dev": true, - "peer": true, - "dependencies": { - "ext-list": "^2.0.0", - "sort-keys-length": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "peer": true, - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "peer": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "peer": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "peer": true - }, - "node_modules/fast-uri": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.3.0.tgz", - "integrity": "sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==", - "dev": true, - "peer": true - }, - "node_modules/fast-xml-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", - "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", - "dev": true, - "funding": [ - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - }, - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "peer": true, - "dependencies": { - "strnum": "^1.0.5" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "peer": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "peer": true, - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", - "dev": true, - "peer": true, - "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/filename-reserved-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", - "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/filenamify": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", - "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", - "dev": true, - "peer": true, - "dependencies": { - "filename-reserved-regex": "^2.0.0", - "strip-outer": "^1.0.1", - "trim-repeated": "^1.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/filesize": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.2.tgz", - "integrity": "sha512-Dx770ai81ohflojxhU+oG+Z2QGvKdYxgEr9OSA8UVrqhwNHjfH9A8f5NKfg83fEH8ZFA5N5llJo5T3PIoZ4CRA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 10.4.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "peer": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-requires": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/find-requires/-/find-requires-1.0.0.tgz", - "integrity": "sha512-UME7hNwBfzeISSFQcBEDemEEskpOjI/shPrpJM5PI4DSdn6hX0dmz+2dL70blZER2z8tSnTRL+2rfzlYgtbBoQ==", - "dev": true, - "peer": true, - "dependencies": { - "es5-ext": "^0.10.49", - "esniff": "^1.1.0" - }, - "bin": { - "find-requires": "bin/find-requires.js" - } - }, - "node_modules/find-requires/node_modules/esniff": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-1.1.3.tgz", - "integrity": "sha512-SLBLpfE7xWgF/HbzhVuAwqnJDRqSCNZqcqaIMVm+f+PbTp1kFRWu6BuT83SATb4Tp+ovr+S+u7vDH7/UErAOkw==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "peer": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "peer": true, - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "peer": true, - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "peer": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formidable": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", - "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", - "dev": true, - "peer": true, - "dependencies": { - "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", - "once": "^1.4.0", - "qs": "^6.11.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "peer": true - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fs2": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/fs2/-/fs2-0.3.9.tgz", - "integrity": "sha512-WsOqncODWRlkjwll+73bAxVW3JPChDgaPX3DT4iTTm73UmG4VgALa7LaFblP232/DN60itkOrPZ8kaP1feksGQ==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.1", - "deferred": "^0.7.11", - "es5-ext": "^0.10.53", - "event-emitter": "^0.3.5", - "ignore": "^5.1.8", - "memoizee": "^0.4.14", - "type": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-stdin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", - "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-all": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/glob-all/-/glob-all-3.3.1.tgz", - "integrity": "sha512-Y+ESjdI7ZgMwfzanHZYQ87C59jOO0i+Hd+QYtVt9PhLi6d8wlOpzQnfBxWUlaTuAoR3TkybLqqbIoWveU4Ji7Q==", - "dev": true, - "dependencies": { - "glob": "^7.2.3", - "yargs": "^15.3.1" - }, - "bin": { - "glob-all": "bin/glob-all" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "peer": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "peer": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "peer": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "peer": true, - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/graphlib": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", - "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", - "dev": true, - "peer": true, - "dependencies": { - "lodash": "^4.17.15" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "peer": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "peer": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true, - "peer": true - }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "peer": true, - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", - "dev": true, - "peer": true - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "dev": true - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", - "dev": true, - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "peer": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", - "dev": true, - "peer": true, - "dependencies": { - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "peer": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-natural-number": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", - "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", - "dev": true, - "peer": true - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-primitive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz", - "integrity": "sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "dev": true, - "peer": true - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "peer": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "dev": true, - "peer": true, - "dependencies": { - "which-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "dev": true, - "peer": true, - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jmespath": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "peer": true - }, - "node_modules/json-colorizer": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/json-colorizer/-/json-colorizer-2.2.2.tgz", - "integrity": "sha512-56oZtwV1piXrQnRNTtJeqRv+B9Y/dXAYLqBBaYl/COcUdoZxgLBLAO88+CnkbT6MxNs0c5E9mPBIb2sFcNz3vw==", - "dev": true, - "peer": true, - "dependencies": { - "chalk": "^2.4.1", - "lodash.get": "^4.4.2" - } - }, - "node_modules/json-colorizer/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-colorizer/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-colorizer/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/json-colorizer/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "peer": true - }, - "node_modules/json-colorizer/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-colorizer/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-cycle": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/json-cycle/-/json-cycle-1.5.0.tgz", - "integrity": "sha512-GOehvd5PO2FeZ5T4c+RxobeT5a1PiGpF4u9/3+UvrMU4bhnVqzJY7hm39wg8PDCqkU91fWGH8qjWR4bn+wgq9w==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/json-refs": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", - "integrity": "sha512-0vOQd9eLNBL18EGl5yYaO44GhixmImes2wiYn9Z3sag3QnehWrYWlB9AFtMxCL2Bj3fyxgDYkxGFEU/chlYssw==", - "dev": true, - "peer": true, - "dependencies": { - "commander": "~4.1.1", - "graphlib": "^2.1.8", - "js-yaml": "^3.13.1", - "lodash": "^4.17.15", - "native-promise-only": "^0.8.1", - "path-loader": "^1.0.10", - "slash": "^3.0.0", - "uri-js": "^4.2.2" - }, - "bin": { - "json-refs": "bin/json-refs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/json-refs/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/json-refs/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "peer": true - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/jszip/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/jszip/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/jwt-decode": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz", - "integrity": "sha512-86GgN2vzfUu7m9Wcj63iUkuDzFNYFVmjeDm2GzWpUk+opB0pEpMsw6ePCMrhYkumz2C1ihqtZzOMAg7FiXcNoQ==", - "dev": true, - "peer": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "peer": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "peer": true - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "peer": true - }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "peer": true - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "peer": true - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "peer": true - }, - "node_modules/lodash.uniqby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", - "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", - "dev": true - }, - "node_modules/lodash.values": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz", - "integrity": "sha512-r0RwvdCv8id9TUblb/O7rYPwVy6lerCbcawrfdo9iC/1t1wsNMJknO79WNBgwkH0hIeJ08jmvvESbFpNb4jH0Q==", - "dev": true - }, - "node_modules/log": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/log/-/log-6.3.1.tgz", - "integrity": "sha512-McG47rJEWOkXTDioZzQNydAVvZNeEkSyLJ1VWkFwfW+o1knW+QSi8D1KjPn/TnctV+q99lkvJNe1f0E1IjfY2A==", - "dev": true, - "dependencies": { - "d": "^1.0.1", - "duration": "^0.2.2", - "es5-ext": "^0.10.53", - "event-emitter": "^0.3.5", - "sprintf-kit": "^2.0.1", - "type": "^2.5.0", - "uni-global": "^1.0.0" - } - }, - "node_modules/log-node": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/log-node/-/log-node-8.0.3.tgz", - "integrity": "sha512-1UBwzgYiCIDFs8A0rM2QdBFo8Wd8UQ0HrSTu/MNI+/2zN3NoHRj2fhplurAyuxTYUXu3Oohugq1jAn5s05u1MQ==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "cli-color": "^2.0.1", - "cli-sprintf-format": "^1.1.1", - "d": "^1.0.1", - "es5-ext": "^0.10.53", - "sprintf-kit": "^2.0.1", - "supports-color": "^8.1.1", - "type": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "peerDependencies": { - "log": "^6.0.0" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "peer": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "dev": true, - "peer": true, - "dependencies": { - "es5-ext": "~0.10.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "peer": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/memoizee": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", - "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", - "dev": true, - "peer": true, - "dependencies": { - "d": "^1.0.2", - "es5-ext": "^0.10.64", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", - "dev": true, - "peer": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "peer": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "peer": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "peer": true - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "peer": true - }, - "node_modules/native-promise-only": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", - "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", - "dev": true, - "peer": true - }, - "node_modules/ncjsm": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ncjsm/-/ncjsm-4.3.2.tgz", - "integrity": "sha512-6d1VWA7FY31CpI4Ki97Fpm36jfURkVbpktizp8aoVViTZRQgr/0ddmlKerALSSlzfwQRBeSq1qwwVcBJK4Sk7Q==", - "dev": true, - "peer": true, - "dependencies": { - "builtin-modules": "^3.3.0", - "deferred": "^0.7.11", - "es5-ext": "^0.10.62", - "es6-set": "^0.1.6", - "ext": "^1.7.0", - "find-requires": "^1.0.0", - "fs2": "^0.3.9", - "type": "^2.7.2" - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true - }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true - }, - "node_modules/node-dir": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", - "integrity": "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==", - "dev": true, - "peer": true, - "dependencies": { - "minimatch": "^3.0.2" - }, - "engines": { - "node": ">= 0.10.5" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-registry-utilities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/npm-registry-utilities/-/npm-registry-utilities-1.0.0.tgz", - "integrity": "sha512-9xYfSJy2IFQw1i6462EJzjChL9e65EfSo2Cw6kl0EFeDp05VvU+anrQk3Fc0d1MbVCq7rWIxeer89O9SUQ/uOg==", - "dev": true, - "peer": true, - "dependencies": { - "ext": "^1.6.0", - "fs2": "^0.3.9", - "memoizee": "^0.4.15", - "node-fetch": "^2.6.7", - "semver": "^7.3.5", - "type": "^2.6.0", - "validate-npm-package-name": "^3.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "peer": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "peer": true, - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "peer": true, - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-event": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", - "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", - "dev": true, - "peer": true, - "dependencies": { - "p-timeout": "^3.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dev": true, - "peer": true, - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/path-loader": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/path-loader/-/path-loader-1.0.12.tgz", - "integrity": "sha512-n7oDG8B+k/p818uweWrOixY9/Dsr89o2TkCm6tOTex3fpdo2+BFDgR+KpB37mGKBRsBAlR8CIJMFN0OEy/7hIQ==", - "dev": true, - "peer": true, - "dependencies": { - "native-promise-only": "^0.8.1", - "superagent": "^7.1.6" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path2": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/path2/-/path2-0.1.0.tgz", - "integrity": "sha512-TX+cz8Jk+ta7IvRy2FAej8rdlbrP0+uBIkP/5DTODez/AuL/vSb30KuAdDxGVREXzn8QfAiu5mJYJ1XjbOhEPA==", - "dev": true, - "peer": true - }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "peer": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", - "dev": true, - "peer": true, - "dependencies": { - "pinkie": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/process-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/process-utils/-/process-utils-4.0.0.tgz", - "integrity": "sha512-fMyMQbKCxX51YxR7YGCzPjLsU3yDzXFkP4oi1/Mt5Ixnk7GO/7uUTj8mrCHUwuvozWzI+V7QSJR9cZYnwNOZPg==", - "dev": true, - "peer": true, - "dependencies": { - "ext": "^1.4.0", - "fs2": "^0.3.9", - "memoizee": "^0.4.14", - "type": "^2.1.0" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/promise-queue": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/promise-queue/-/promise-queue-2.2.5.tgz", - "integrity": "sha512-p/iXrPSVfnqPft24ZdNNLECw/UrtLTpT3jpAAMzl/o5/rDsGCPo3/CQS2611flL6LkoEJ3oQZw7C8Q80ZISXRQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "peer": true - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "peer": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", - "dev": true, - "peer": true, - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystring": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", - "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", - "dev": true, - "peer": true, - "dependencies": { - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "peer": true - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "peer": true, - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "peer": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "peer": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/run-parallel-limit": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz", - "integrity": "sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "peer": true - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-regex": "^1.1.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "peer": true - }, - "node_modules/sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", - "dev": true, - "peer": true - }, - "node_modules/seek-bzip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", - "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", - "dev": true, - "peer": true, - "dependencies": { - "commander": "^2.8.1" - }, - "bin": { - "seek-bunzip": "bin/seek-bunzip", - "seek-table": "bin/seek-bzip-table" - } - }, - "node_modules/seek-bzip/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serverless": { - "version": "3.38.0", - "resolved": "https://registry.npmjs.org/serverless/-/serverless-3.38.0.tgz", - "integrity": "sha512-NJE1vOn8XmQEqfU9UxmVhkUFaCRmx6FhYw/jITN863WlOt4Y3PQbj3hwQyIb5QS1ZrXFq5ojklwewUXH7xGpdA==", - "dev": true, - "hasInstallScript": true, - "peer": true, - "dependencies": { - "@serverless/dashboard-plugin": "^7.2.0", - "@serverless/platform-client": "^4.5.1", - "@serverless/utils": "^6.13.1", - "abort-controller": "^3.0.0", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "archiver": "^5.3.1", - "aws-sdk": "^2.1404.0", - "bluebird": "^3.7.2", - "cachedir": "^2.3.0", - "chalk": "^4.1.2", - "child-process-ext": "^2.1.1", - "ci-info": "^3.8.0", - "cli-progress-footer": "^2.3.2", - "d": "^1.0.1", - "dayjs": "^1.11.8", - "decompress": "^4.2.1", - "dotenv": "^16.3.1", - "dotenv-expand": "^10.0.0", - "essentials": "^1.2.0", - "ext": "^1.7.0", - "fastest-levenshtein": "^1.0.16", - "filesize": "^10.0.7", - "fs-extra": "^10.1.0", - "get-stdin": "^8.0.0", - "globby": "^11.1.0", - "graceful-fs": "^4.2.11", - "https-proxy-agent": "^5.0.1", - "is-docker": "^2.2.1", - "js-yaml": "^4.1.0", - "json-colorizer": "^2.2.2", - "json-cycle": "^1.5.0", - "json-refs": "^3.0.15", - "lodash": "^4.17.21", - "memoizee": "^0.4.15", - "micromatch": "^4.0.5", - "node-fetch": "^2.6.11", - "npm-registry-utilities": "^1.0.0", - "object-hash": "^3.0.0", - "open": "^8.4.2", - "path2": "^0.1.0", - "process-utils": "^4.0.0", - "promise-queue": "^2.2.5", - "require-from-string": "^2.0.2", - "semver": "^7.5.3", - "signal-exit": "^3.0.7", - "stream-buffers": "^3.0.2", - "strip-ansi": "^6.0.1", - "supports-color": "^8.1.1", - "tar": "^6.1.15", - "timers-ext": "^0.1.7", - "type": "^2.7.2", - "untildify": "^4.0.0", - "uuid": "^9.0.0", - "ws": "^7.5.9", - "yaml-ast-parser": "0.0.43" - }, - "bin": { - "serverless": "bin/serverless.js", - "sls": "bin/serverless.js" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/serverless-dotenv-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serverless-dotenv-plugin/-/serverless-dotenv-plugin-6.0.0.tgz", - "integrity": "sha512-8tLVNwHfDO0sBz6+m+DLTZquRk0AZq9rzqk3kphm1iIWKfan9R7RKt4hdq3eQ0kmDoqzudjPYBEXAJ5bUNKeGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "dotenv": "^16.0.3", - "dotenv-expand": "^10.0.0" - }, - "peerDependencies": { - "serverless": "1 || 2 || pre-3 || 3" - } - }, - "node_modules/serverless-python-requirements": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/serverless-python-requirements/-/serverless-python-requirements-6.1.2.tgz", - "integrity": "sha512-pas27CBxxaLTU5XMYnCVPJc+LVdm65Ys5olNvRWRqfUaZwTfD/7KSSt2XPSRme8BeJubroslaiOtWPP+IrxTVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@iarna/toml": "^2.2.5", - "appdirectory": "^0.1.0", - "bluebird": "^3.7.2", - "child-process-ext": "^2.1.1", - "fs-extra": "^10.1.0", - "glob-all": "^3.3.1", - "is-wsl": "^2.2.0", - "jszip": "^3.10.1", - "lodash.get": "^4.4.2", - "lodash.uniqby": "^4.7.0", - "lodash.values": "^4.3.0", - "rimraf": "^3.0.2", - "semver": "^7.6.0", - "set-value": "^4.1.0", - "sha256-file": "1.0.0", - "shell-quote": "^1.8.1" - }, - "engines": { - "node": ">=12.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "peer": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "peer": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-value": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-4.1.0.tgz", - "integrity": "sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==", - "dev": true, - "funding": [ - "https://github.com/sponsors/jonschlinkert", - "https://paypal.me/jonathanschlinkert", - "https://jonschlinkert.dev/sponsor" - ], - "dependencies": { - "is-plain-object": "^2.0.4", - "is-primitive": "^3.0.1" - }, - "engines": { - "node": ">=11.0" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true - }, - "node_modules/sha256-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/sha256-file/-/sha256-file-1.0.0.tgz", - "integrity": "sha512-nqf+g0veqgQAkDx0U2y2Tn2KWyADuuludZTw9A7J3D+61rKlIIl9V5TS4mfnwKuXZOH9B7fQyjYJ9pKRHIsAyg==", - "dev": true - }, - "node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "peer": true - }, - "node_modules/simple-git": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.24.0.tgz", - "integrity": "sha512-QqAKee9Twv+3k8IFOFfPB2hnk6as6Y6ACUpwCtQvRYBAes23Wv3SZlHVobAzqcE8gfsisCvPw3HGW3HYM+VYYw==", - "dev": true, - "peer": true, - "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.3.4" - }, - "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sort-keys": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", - "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", - "dev": true, - "peer": true, - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sort-keys-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", - "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", - "dev": true, - "peer": true, - "dependencies": { - "sort-keys": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "dev": true, - "dependencies": { - "readable-stream": "^3.0.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "peer": true - }, - "node_modules/sprintf-kit": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sprintf-kit/-/sprintf-kit-2.0.2.tgz", - "integrity": "sha512-lnapdj6W4LflHZGKvl9eVkz5YF0xaTrqpRWVA4cNVOTedwqifIP8ooGImldzT/4IAN5KXFQAyXTdLidYVQdyag==", - "dev": true, - "dependencies": { - "es5-ext": "^0.10.64" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/stream-buffers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz", - "integrity": "sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/stream-promise": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/stream-promise/-/stream-promise-3.2.0.tgz", - "integrity": "sha512-P+7muTGs2C8yRcgJw/PPt61q7O517tDHiwYEzMWo1GSBCcZedUMT/clz7vUNsSxFphIlJ6QUL4GexQKlfJoVtA==", - "dev": true, - "dependencies": { - "2-thenable": "^1.0.0", - "es5-ext": "^0.10.49", - "is-stream": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-dirs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", - "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", - "dev": true, - "peer": true, - "dependencies": { - "is-natural-number": "^4.0.1" - } - }, - "node_modules/strip-outer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", - "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", - "dev": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "dev": true, - "peer": true - }, - "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", - "dev": true, - "peer": true, - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/superagent": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", - "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", - "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", - "dev": true, - "peer": true, - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.0.1", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.10.3", - "readable-stream": "^3.6.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "peer": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "dev": true, - "peer": true - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "peer": true - }, - "node_modules/timers-ext": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", - "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", - "dev": true, - "peer": true, - "dependencies": { - "es5-ext": "^0.10.64", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "peer": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/to-buffer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", - "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", - "dev": true, - "peer": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "peer": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/token-types": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", - "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", - "dev": true, - "peer": true, - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/token-types/node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "peer": true - }, - "node_modules/traverse": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", - "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", - "dev": true, - "peer": true, - "dependencies": { - "gopd": "^1.0.1", - "typedarray.prototype.slice": "^1.0.3", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/trim-repeated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", - "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", - "dev": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true, - "peer": true - }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "dev": true - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", - "dev": true, - "peer": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typedarray.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", - "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-errors": "^1.3.0", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-offset": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, - "peer": true, - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, - "node_modules/unbzip2-stream/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "peer": true - }, - "node_modules/uni-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/uni-global/-/uni-global-1.0.0.tgz", - "integrity": "sha512-WWM3HP+siTxzIWPNUg7hZ4XO8clKi6NoCAJJWnuRL+BAqyFXF8gC03WNyTefGoUXYc47uYgXxpKLIEvo65PEHw==", - "dev": true, - "dependencies": { - "type": "^2.5.0" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "peer": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", - "dev": true, - "peer": true, - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", - "dev": true, - "peer": true - }, - "node_modules/url/node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", - "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", - "dev": true, - "peer": true, - "dependencies": { - "builtins": "^1.0.3" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "peer": true, - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "peer": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "peer": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true - }, - "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", - "dev": true, - "peer": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "peer": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dev": true, - "peer": true, - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "peer": true - }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "peer": true - }, - "node_modules/yamljs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", - "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "glob": "^7.0.5" - }, - "bin": { - "json2yaml": "bin/json2yaml", - "yaml2json": "bin/yaml2json" - } - }, - "node_modules/yamljs/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "peer": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "dev": true, - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "dev": true, - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "peer": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 133a7b3..0000000 --- a/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "lambda-gurumi-ai-bot", - "devDependencies": { - "serverless-dotenv-plugin": "^6.0.0", - "serverless-python-requirements": "^6.1.2" - } -} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..974aea5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -v --tb=short +filterwarnings = + ignore::DeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..84ae17e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=8.0 +pytest-cov>=5.0 +moto[dynamodb]>=5.0 +responses>=0.25 +reportlab>=4.0,<5.0 diff --git a/requirements.txt b/requirements.txt index ef55c65..7aa7579 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ -boto3>=1.42,<2.0 -slack-bolt>=1.27,<2.0 -slack-sdk>=3.39,<4.0 +slack-bolt>=1.20.1,<2.0 +slack-sdk>=3.31.0,<4.0 +boto3>=1.34.0,<2.0 +openai>=1.50.0,<2.0 +python-dotenv>=1.0.0,<2.0 +pypdf>=4.0,<6.0 diff --git a/scripts/awsdocs/docs.txt b/scripts/awsdocs/docs.txt deleted file mode 100644 index 6176a39..0000000 --- a/scripts/awsdocs/docs.txt +++ /dev/null @@ -1,24 +0,0 @@ -# AWS official PDF documentation to sync to Knowledge Base -# Format: name url -# Lines starting with # are ignored -# Add or remove docs as needed - -s3 https://docs.aws.amazon.com/pdfs/AmazonS3/latest/userguide/s3-userguide.pdf -dynamodb https://docs.aws.amazon.com/pdfs/amazondynamodb/latest/developerguide/dynamodb-dg.pdf -lambda https://docs.aws.amazon.com/pdfs/lambda/latest/dg/lambda-dg.pdf -eks https://docs.aws.amazon.com/pdfs/eks/latest/userguide/eks-ug.pdf -rds https://docs.aws.amazon.com/pdfs/AmazonRDS/latest/UserGuide/rds-ug.pdf -iam https://docs.aws.amazon.com/pdfs/IAM/latest/UserGuide/iam-ug.pdf -bedrock https://docs.aws.amazon.com/pdfs/bedrock/latest/userguide/bedrock-ug.pdf -ec2 https://docs.aws.amazon.com/pdfs/AWSEC2/latest/UserGuide/ec2-ug.pdf -vpc https://docs.aws.amazon.com/pdfs/vpc/latest/userguide/vpc-ug.pdf -ecs https://docs.aws.amazon.com/pdfs/AmazonECS/latest/developerguide/ecs-dg.pdf -cloudformation https://docs.aws.amazon.com/pdfs/AWSCloudFormation/latest/UserGuide/cfn-ug.pdf -well-architected https://docs.aws.amazon.com/pdfs/wellarchitected/latest/framework/wellarchitected-framework.pdf -apigateway https://docs.aws.amazon.com/pdfs/apigateway/latest/developerguide/apigateway-dg.pdf -stepfunctions https://docs.aws.amazon.com/pdfs/step-functions/latest/dg/step-functions-dg.pdf -cognito https://docs.aws.amazon.com/pdfs/cognito/latest/developerguide/cognito-dg.pdf -cloudwatch https://docs.aws.amazon.com/pdfs/AmazonCloudWatch/latest/monitoring/acw-ug.pdf -systems-manager https://docs.aws.amazon.com/pdfs/systems-manager/latest/userguide/systems-manager-ug.pdf -route53 https://docs.aws.amazon.com/pdfs/Route53/latest/DeveloperGuide/route53-dg.pdf -aurora https://docs.aws.amazon.com/pdfs/AmazonRDS/latest/AuroraUserGuide/aurora-ug.pdf diff --git a/scripts/awsdocs/sync.sh b/scripts/awsdocs/sync.sh deleted file mode 100755 index 309f427..0000000 --- a/scripts/awsdocs/sync.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Sync AWS official PDF documentation to S3 for Knowledge Base ingestion -# Usage: ./sync.sh [docs-file] -# -# docs.txt format: name url -# s3 https://docs.aws.amazon.com/pdfs/AmazonS3/latest/userguide/s3-userguide.pdf -# -# PDFs over 50MB are automatically split into smaller parts using qpdf. - -S3_BUCKET="${1:?Usage: sync.sh [docs-file]}" -DOCS_FILE="${2:-$(dirname "$0")/docs.txt}" -BUILD_DIR="build/awsdocs" -MAX_FILE_SIZE=$((50 * 1024 * 1024)) # 50MB Bedrock limit -SPLIT_PAGES=100 # Pages per split chunk - -rm -rf "${BUILD_DIR}" -mkdir -p "${BUILD_DIR}" - -# Read docs list, skip comments and empty lines -grep -v '^\s*#' "${DOCS_FILE}" | grep -v '^\s*$' | while read -r name url; do - echo "=== Downloading ${name} ===" - out_dir="${BUILD_DIR}/${name}" - mkdir -p "${out_dir}" - - curl -sL -o "${out_dir}/${name}.pdf" "${url}" - file_size=$(stat -f%z "${out_dir}/${name}.pdf" 2>/dev/null || stat -c%s "${out_dir}/${name}.pdf" 2>/dev/null) - size_mb=$(echo "scale=1; ${file_size}/1048576" | bc) - echo "Downloaded ${name}.pdf (${size_mb}MB)" - - # Split large PDFs into smaller parts - if [ "${file_size}" -gt "${MAX_FILE_SIZE}" ]; then - echo "Splitting ${name}.pdf (exceeds 50MB limit)..." - qpdf --split-pages="${SPLIT_PAGES}" "${out_dir}/${name}.pdf" "${out_dir}/${name}-%d.pdf" - rm -f "${out_dir}/${name}.pdf" - split_count=$(find "${out_dir}" -name '*.pdf' | wc -l | tr -d ' ') - echo "Split into ${split_count} parts" - fi - - # Sync to S3 - echo "Syncing to s3://${S3_BUCKET}/documents/${name}/" - aws s3 sync --delete \ - "${out_dir}/" \ - "s3://${S3_BUCKET}/documents/${name}/" - - rm -rf "${out_dir}" - echo "" -done - -echo "=== All docs synced ===" diff --git a/scripts/bedrock/README.md b/scripts/bedrock/README.md deleted file mode 100644 index 6e6fed7..0000000 --- a/scripts/bedrock/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Bedrock 예제 - -## Install - -```bash -$ brew install python@3.12 - -$ python -m pip install --upgrade -r requirements.txt -``` - -## Test - -```bash -# Bedrock Agent 테스트 -python invoke_agent.py -p "AWS DeepRacer 설명해줘" - -# Claude 3 모델 직접 호출 -python invoke_claude_3.py -p "구름이가 누구?" - -# Claude 3 이미지 분석 -python invoke_claude_3_image.py - -# 이미지 생성 (Stable Diffusion) -python invoke_stable_diffusion.py -p "Create an image of a cat walking on a fully frozen river surface on a cold winter day." - -# Knowledge Base 쿼리 -python invoke_knowledge_base.py -p "지식 베이스 쿼리" - -# 스트리밍 대화 -python converse_stream.py -p "프롬프트 입력" -``` - -## References - -* -* diff --git a/scripts/bedrock/converse_stream.py b/scripts/bedrock/converse_stream.py deleted file mode 100644 index 7603eab..0000000 --- a/scripts/bedrock/converse_stream.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import os - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -MODEL_ID_TEXT = "anthropic.claude-3-5-sonnet-20240620-v1:0" - -# Set up System messages -PERSONAL_MESSAGE = os.environ.get( - "PERSONAL_MESSAGE", "당신은 친절하고 전문적인 AI 비서 입니다." -) -SYSTEM_MESSAGE = "답변을 할때 참고한 문서가 있다면 링크도 알려줘." - - -# Initialize the Amazon Bedrock runtime client -bedrock = boto3.client(service_name="bedrock", region_name=AWS_REGION) - -bedrock_runtime = boto3.client(service_name="bedrock-runtime", region_name=AWS_REGION) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def create_inference_profile(): - profile_name = "gurumi-ai-bot-inference-profile" - - model_arn = "arn:aws:bedrock:{}::foundation-model/{}".format( - AWS_REGION, MODEL_ID_TEXT - ) - - tags = [ - {"key": "Name", "value": "gurumi-ai-bot"}, - {"key": "dept", "value": "sre"}, - ] - - """Create Inference Profile using base model ARN""" - response = bedrock.create_inference_profile( - inferenceProfileName=profile_name, - description="gurumi-ai-bot Inference Profile", - modelSource={"copyFrom": model_arn}, - tags=tags, - ) - - profile_arn = response["inferenceProfileArn"] - - return profile_arn - - -def converse_stream(prompt): - try: - model_id = create_inference_profile() - - messages = [ - { - "role": "user", - "content": [{"text": prompt}], - }, - ] - - streaming_response = bedrock_runtime.converse_stream( - modelId=model_id, - messages=messages, - inferenceConfig={"maxTokens": 4096, "temperature": 0.5, "topP": 0.9}, - ) - - # Extract and print the streamed response text in real-time. - for chunk in streaming_response["stream"]: - if "contentBlockDelta" in chunk: - text = chunk["contentBlockDelta"]["delta"]["text"] - print(text, end="") - - except Exception as e: - print("converse_stream: Error: {}".format(e)) - - raise e - - -def main(): - args = parse_args() - - query = args.prompt - - prompts = [] - prompts.append("User: {}".format(PERSONAL_MESSAGE)) - prompts.append( - "If you don't know the answer, just say that you don't know, don't try to make up an answer." - ) - - if SYSTEM_MESSAGE != "None": - prompts.append(SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Add the question to the prompts - prompts.append("") - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - # Combine the prompts - prompt = "\n".join(prompts) - - # Send the prompt to Bedrock - converse_stream(prompt) - - except Exception as e: - print("conversation: error: {}".format(e)) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_agent.py b/scripts/bedrock/invoke_agent.py deleted file mode 100644 index f346dda..0000000 --- a/scripts/bedrock/invoke_agent.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import boto3 -import os -import asyncio - -from datetime import datetime - -from botocore.exceptions import ClientError - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -AGENT_ID = "LIKTFQ14NA" -AGENT_ALIAS_ID = "Y1M3VE0PR4" - - -# Initialize the Amazon Bedrock agent runtime client -bedrock = boto3.client(service_name="bedrock-agent-runtime", region_name=AWS_REGION) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_agent") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def invoke_agent(prompt): - """ - Sends a prompt for the agent to process and respond to. - - :param agent_id: The unique identifier of the agent to use. - :param agent_alias_id: The alias of the agent to use. - :param session_id: The unique identifier of the session. Use the same value across requests - to continue the same conversation. - :param prompt: The prompt that you want Claude to complete. - :return: Inference response from the model. - """ - - session_id = datetime.now().strftime("%Y%m%d%H%M%S") - - try: - # Note: The execution time depends on the foundation model, complexity of the agent, - # and the length of the prompt. In some cases, it can take up to a minute or more to - # generate a response. - response = bedrock.invoke_agent( - agentId=AGENT_ID, - agentAliasId=AGENT_ALIAS_ID, - sessionId=session_id, - inputText=prompt, - ) - - completion = "" - - for event in response.get("completion"): - chunk = event["chunk"] - completion = completion + chunk["bytes"].decode() - - except ClientError as e: - print(f"Couldn't invoke agent. {e}") - raise - - return completion - - -async def main(): - args = parse_args() - - try: - # Send the prompt to Bedrock Agent - message = invoke_agent(args.prompt) - - print(message) - - except Exception as e: - print(f"error: {e}") - - -if __name__ == "__main__": - # main() - asyncio.run(main()) diff --git a/scripts/bedrock/invoke_claude_3.py b/scripts/bedrock/invoke_claude_3.py deleted file mode 100644 index ce3b2ec..0000000 --- a/scripts/bedrock/invoke_claude_3.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import os - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -ANTHROPIC_VERSION = os.environ.get("ANTHROPIC_VERSION", "bedrock-2023-05-31") -ANTHROPIC_TOKENS = int(os.environ.get("ANTHROPIC_TOKENS", 1024)) - -MODEL_ID_TEXT = "anthropic.claude-3-5-sonnet-20240620-v1:0" - -# Set up System messages -PERSONAL_MESSAGE = os.environ.get( - "PERSONAL_MESSAGE", "당신은 친절하고 전문적인 AI 비서 입니다." -) -SYSTEM_MESSAGE = "답변을 할때 참고한 문서가 있다면 링크도 알려줘." - - -# Initialize the Amazon Bedrock runtime client -bedrock = boto3.client(service_name="bedrock-runtime", region_name=AWS_REGION) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def invoke_claude_3(prompt): - """ - Invokes Anthropic Claude 3 Sonnet to run an inference using the input - provided in the request body. - - :param prompt: The prompt that you want Claude 3 to complete. - :return: Inference response from the model. - """ - - try: - body = { - "anthropic_version": ANTHROPIC_VERSION, - "max_tokens": ANTHROPIC_TOKENS, - "messages": [ - { - "role": "user", - "content": [{"type": "text", "text": prompt}], - }, - ], - } - - response = bedrock.invoke_model( - modelId=MODEL_ID_TEXT, - body=json.dumps(body), - ) - - # Process and print the response - body = json.loads(response.get("body").read()) - - print("response: {}".format(body)) - - result = body.get("content", []) - - for output in result: - text = output["text"] - - return text - - except Exception as e: - print("invoke_claude_3: Error: {}".format(e)) - - raise e - - -def main(): - args = parse_args() - - query = args.prompt - - prompts = [] - prompts.append("User: {}".format(PERSONAL_MESSAGE)) - prompts.append("If you don't know the answer, just say that you don't know, don't try to make up an answer.") - - if SYSTEM_MESSAGE != "None": - prompts.append(SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Add the question to the prompts - prompts.append("") - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - # Combine the prompts - prompt = "\n".join(prompts) - - # Send the prompt to Bedrock - message = invoke_claude_3(prompt) - - print("conversation: message: {}".format(message)) - - except Exception as e: - print("conversation: error: {}".format(e)) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_claude_3_image.py b/scripts/bedrock/invoke_claude_3_image.py deleted file mode 100644 index e11453b..0000000 --- a/scripts/bedrock/invoke_claude_3_image.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import base64 - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="사진 설명 해줘", help="prompt") - return p.parse_args() - - -def invoke_claude_3(prompt): - """ - Invokes Anthropic Claude 3 Sonnet to run an inference using the input - provided in the request body. - - :param prompt: The prompt that you want Claude 3 to complete. - :return: Inference response from the model. - """ - - # Initialize the Amazon Bedrock runtime client - bedrock = boto3.client(service_name="bedrock-runtime", region_name="us-east-1") - - # Invoke Claude 3 with the text prompt - model_id = "anthropic.claude-3-sonnet-20240229-v1:0" - - image = "../images/gurumi-bot.png" - - # Read reference image from file and encode as base64 strings. - with open(image, "rb") as file: - encoded_image = base64.b64encode(file.read()).decode("utf8") - - try: - body = { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": prompt, - }, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": encoded_image, - }, - }, - ], - }, - ], - } - - # print("request: {}".format(body)) - - response = bedrock.invoke_model( - modelId=model_id, - body=json.dumps(body), - ) - - # Process and print the response - body = json.loads(response.get("body").read()) - - # print("response: {}".format(body)) - - content = body.get("content", []) - - for output in content: - print(output["text"]) - - except Exception as e: - print("Error: {}".format(e)) - - -def main(): - args = parse_args() - - invoke_claude_3(args.prompt) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_knowledge_base.py b/scripts/bedrock/invoke_knowledge_base.py deleted file mode 100644 index 2771862..0000000 --- a/scripts/bedrock/invoke_knowledge_base.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import os - -from botocore.client import Config - - -AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") - -# Amazon Bedrock Knowledge Base ID -KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "IYEMTLJ9MM") - -KB_RETRIEVE_COUNT = int(os.environ.get("KB_RETRIEVE_COUNT", 5)) - -# Amazon Bedrock Model ID -ANTHROPIC_VERSION = os.environ.get("ANTHROPIC_VERSION", "bedrock-2023-05-31") -ANTHROPIC_TOKENS = int(os.environ.get("ANTHROPIC_TOKENS", 1024)) - -MODEL_ID_TEXT = "anthropic.claude-3-sonnet-20240229-v1:0" - -# Set up System messages -PERSONAL_MESSAGE = os.environ.get( - "PERSONAL_MESSAGE", "당신은 친절하고 전문적인 AI 비서 입니다." -) -SYSTEM_MESSAGE = "답변을 할때 참고한 문서가 있다면 링크도 알려줘." - - -# Initialize the Amazon Bedrock runtime client -bedrock = boto3.client(service_name="bedrock-runtime", region_name=AWS_REGION) - -bedrock_config = Config( - connect_timeout=120, read_timeout=120, retries={"max_attempts": 0} -) -bedrock_agent_client = boto3.client( - "bedrock-agent-runtime", region_name=AWS_REGION, config=bedrock_config -) - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_claude_3") - p.add_argument("-p", "--prompt", default="안녕", help="prompt") - return p.parse_args() - - -def invoke_knowledge_base(content): - """ - Invokes the Amazon Bedrock Knowledge Base to retrieve information using the input - provided in the request body. - - :param content: The content that you want to use for retrieval. - :return: The retrieved contexts from the knowledge base. - """ - - contexts = [] - - if KNOWLEDGE_BASE_ID == "None": - return contexts - - try: - response = bedrock_agent_client.retrieve( - retrievalQuery={"text": content}, - knowledgeBaseId=KNOWLEDGE_BASE_ID, - retrievalConfiguration={ - "vectorSearchConfiguration": { - "numberOfResults": KB_RETRIEVE_COUNT, - # "overrideSearchType": "HYBRID", # optional - } - }, - ) - - results = response["retrievalResults"] - - contexts = [] - for result in results: - contexts.append(result["content"]["text"]) - - except Exception as e: - print("invoke_knowledge_base: Error: {}".format(e)) - - print("invoke_knowledge_base: {}".format("\n\n".join(contexts))) - - return contexts - - -def invoke_claude_3(prompt): - """ - Invokes Anthropic Claude 3 Sonnet to run an inference using the input - provided in the request body. - - :param prompt: The prompt that you want Claude 3 to complete. - :return: Inference response from the model. - """ - - try: - body = { - "anthropic_version": ANTHROPIC_VERSION, - "max_tokens": ANTHROPIC_TOKENS, - "messages": [ - { - "role": "user", - "content": [{"type": "text", "text": prompt}], - }, - ], - } - - response = bedrock.invoke_model( - modelId=MODEL_ID_TEXT, - body=json.dumps(body), - ) - - # Process and print the response - body = json.loads(response.get("body").read()) - - print("response: {}".format(body)) - - result = body.get("content", []) - - for output in result: - text = output["text"] - - return text - - except Exception as e: - print("invoke_claude_3: Error: {}".format(e)) - - raise e - - -def main(): - args = parse_args() - - query = args.prompt - - prompts = [] - prompts.append("User: {}".format(PERSONAL_MESSAGE)) - prompts.append("If you don't know the answer, just say that you don't know, don't try to make up an answer.") - - if SYSTEM_MESSAGE != "None": - prompts.append(SYSTEM_MESSAGE) - - prompts.append(" 태그로 감싸진 질문에 답변을 제공하세요.") - - try: - # Get the knowledge base contexts - if KNOWLEDGE_BASE_ID != "None": - contexts = invoke_knowledge_base(query) - - prompts.append( - " 에 정보가 제공 되면, 해당 정보를 사용하여 답변해 주세요." - ) - prompts.append("") - prompts.append("\n\n".join(contexts)) - prompts.append("") - - # Add the question to the prompts - prompts.append("") - prompts.append("") - prompts.append(query) - prompts.append("") - prompts.append("") - - prompts.append("Assistant:") - - # Combine the prompts - prompt = "\n".join(prompts) - - # Send the prompt to Bedrock - message = invoke_claude_3(prompt) - - print("conversation: message: {}".format(message)) - - except Exception as e: - print("conversation: error: {}".format(e)) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/invoke_stable_diffusion.py b/scripts/bedrock/invoke_stable_diffusion.py deleted file mode 100644 index 4fcdf86..0000000 --- a/scripts/bedrock/invoke_stable_diffusion.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import argparse -import json -import boto3 -import base64 -import io - -from PIL import Image - - -def parse_args(): - p = argparse.ArgumentParser(description="invoke_stable_diffusion") - p.add_argument("-p", "--prompt", default="Hello", help="prompt", required=True) - return p.parse_args() - - -def invoke_stable_diffusion(prompt, seed=0, style_preset="photographic"): - """ - Invokes the Stability.ai Stable Diffusion XL model to create an image using - the input provided in the request body. - - :param prompt: The prompt that you want Stable Diffusion to use for image generation. - :param seed: Random noise seed (omit this option or use 0 for a random seed) - :param style_preset: Pass in a style preset to guide the image model towards - a particular style. - :return: Base64-encoded inference response from the model. - """ - - # Initialize the Amazon Bedrock runtime client - bedrock = boto3.client(service_name="bedrock-runtime", region_name="us-east-1") - - # Invoke Claude 3 with the text prompt - model_id = "stability.stable-diffusion-xl-v1" - - try: - # The different model providers have individual request and response formats. - # For the format, ranges, and available style_presets of Stable Diffusion models refer to: - # https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-stability-diffusion.html - - body = { - "text_prompts": [{"text": prompt}], - "seed": seed, - "cfg_scale": 10, - "steps": 30, - "samples": 1, - } - - if style_preset: - body["style_preset"] = style_preset - - response = bedrock.invoke_model( - modelId=model_id, - body=json.dumps(body), - ) - - body = json.loads(response["body"].read()) - - # body["artifacts"][0]["base64"] = None - # print("response: {}".format(body)) - - base64_image = body.get("artifacts")[0].get("base64") - base64_bytes = base64_image.encode("ascii") - image_bytes = base64.b64decode(base64_bytes) - - image = Image.open(io.BytesIO(image_bytes)) - image.show() - - except Exception as e: - print("Error: {}".format(e)) - - -def main(): - args = parse_args() - - invoke_stable_diffusion(args.prompt) - - -if __name__ == "__main__": - main() diff --git a/scripts/bedrock/requirements.txt b/scripts/bedrock/requirements.txt deleted file mode 100644 index 0233d7a..0000000 --- a/scripts/bedrock/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -boto3 -slack-bolt -slack-sdk -pillow diff --git a/scripts/notion/.env.example b/scripts/notion/.env.example deleted file mode 100644 index 9279668..0000000 --- a/scripts/notion/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -NOTION_TOKEN="ntn_xxxx" - -NOTION_PAGE_NAME="nalbam" -NOTION_PAGE_ID="7aace0412a82431996f61a29225a95ec" diff --git a/scripts/notion/README.md b/scripts/notion/README.md deleted file mode 100644 index 8d321ee..0000000 --- a/scripts/notion/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Notion Exporter - -Notion 페이지를 Markdown으로 내보내는 스크립트입니다. 공식 Notion API를 사용합니다. - -## 설치 - -```bash -pip install -r requirements.txt -``` - -## 설정 - -```bash -cp .env.example .env -# .env 파일에 Notion Integration API 토큰 입력 -``` - -Notion Integration 생성: https://www.notion.so/my-integrations - -## 실행 - -```bash -python export.py -``` - -`build/{NOTION_PAGE_NAME}/` 디렉토리에 Markdown 파일이 생성됩니다. - -## References - -- https://github.com/indox-ai/notion-exporter -- https://developers.notion.com/ diff --git a/scripts/notion/export.py b/scripts/notion/export.py deleted file mode 100644 index 849203a..0000000 --- a/scripts/notion/export.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import pathlib - -from notion_exporter import NotionExporter - - -NOTION_TOKEN = os.getenv("NOTION_TOKEN") - -NOTION_PAGE_NAME = os.getenv("NOTION_PAGE_NAME", "demo") -NOTION_PAGE_ID = os.getenv("NOTION_PAGE_ID", "7aace0412a82431996f61a29225a95ec") - - -if __name__ == "__main__": - if not NOTION_TOKEN: - raise ValueError("NOTION_TOKEN environment variable is required") - - exporter = NotionExporter( - notion_token=NOTION_TOKEN, - export_child_pages=True, - ) - exported_pages = exporter.export_pages( - page_ids=[NOTION_PAGE_ID], - ) - - output_dir = pathlib.Path("build") / NOTION_PAGE_NAME - output_dir.mkdir(parents=True, exist_ok=True) - - for page_id, content in exported_pages.items(): - file_path = output_dir / f"{page_id}.md" - file_path.write_text(content, encoding="utf-8") - print(f"Exported: {file_path}") - - print(f"Exported {len(exported_pages)} pages to {output_dir}") diff --git a/scripts/notion/requirements.txt b/scripts/notion/requirements.txt deleted file mode 100644 index bade675..0000000 --- a/scripts/notion/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -notion-exporter diff --git a/scripts/split.py b/scripts/split.py deleted file mode 100644 index 2e93553..0000000 --- a/scripts/split.py +++ /dev/null @@ -1,124 +0,0 @@ -def split_message(message, max_len): - split_parts = [] - - # 먼저 ``` 기준으로 분리 - parts = message.split("```") - - for i, part in enumerate(parts): - if i % 2 == 1: # 코드 블록인 경우 - # 코드 블록도 "\n\n" 기준으로 자름 - split_parts.extend(split_code_block(part, max_len)) - else: # 일반 텍스트 부분 - split_parts.extend(split_by_newline(part, max_len)) - - # 전체 블록을 합친 후 max_len을 넘지 않도록 추가로 자름 - return finalize_split(split_parts, max_len) - - -def split_code_block(code, max_len): - # 코드 블록을 "\n\n" 기준으로 분리 후, 다시 ```로 감쌈 - code_parts = code.split("\n\n") - result = [] - current_part = "```\n" - - for part in code_parts: - if len(current_part) + len(part) + 2 < max_len - 6: # 6은 ``` 앞뒤 길이 - if current_part != "```\n": - current_part += "\n\n" + part - else: - current_part += part - else: - result.append(current_part + "\n```") # ```로 감쌈 - current_part = "```\n" + part - - if current_part != "```\n": - result.append(current_part + "\n```") - - return result - - -def split_by_newline(text, max_len): - # "\n\n" 기준으로 분리 - parts = text.split("\n\n") - result = [] - current_part = "" - - for part in parts: - if len(current_part) + len(part) + 2 < max_len: # 2는 "\n\n"의 길이 - if current_part != "": - current_part += "\n\n" + part - else: - current_part = part - else: - result.append(current_part) - current_part = part - if current_part != "": - result.append(current_part) - - return result - - -def finalize_split(parts, max_len): - # 각 파트를 max_len에 맞춰 추가로 자름 - result = [] - current_message = "" - - for part in parts: - if len(current_message) + len(part) < max_len: - current_message += "\n\n" + part - else: - result.append(current_message) - current_message = part - if current_message != "": - result.append(current_message) - - return result - - -# 테스트 -message = """ -JSON 데이터를 정렬하는 방법은 사용하는 프로그래밍 언어나 도구에 따라 다를 수 있습니다. 여기서는 Python을 사용한 예시를 보여드리겠습니다. Python의 json 모듈을 사용하면 쉽게 JSON 데이터를 정렬할 수 있습니다. - -```python -import json - -# 정렬되지 않은 JSON 데이터 -data = { - "name": "Alice", - "age": 30, - "city": "New York", - "hobbies": ["reading", "hiking", "coding"] -} - -# JSON 데이터를 정렬하여 문자열로 변환 -sorted_json_str = json.dumps(data, indent=4, sort_keys=True) - -# 정렬되지 않은 JSON 데이터 -data = { - "name": "Alice", - "age": 30, - "city": "New York", - "hobbies": ["reading", "hiking", "coding"] -} - -# JSON 데이터를 정렬하여 문자열로 변환 -sorted_json_str = json.dumps(data, indent=4, sort_keys=True) -``` - -```python -# 정렬된 JSON 출력 -print(sorted_json_str) -``` - -이와 같은 방법으로 JSON 데이터를 정렬할 수 있습니다. 다른 프로그래밍 언어에서도 유사한 방법으로 JSON 데이터를 정렬할 수 있으니, 사용하는 언어의 JSON 라이브러리를 참고하세요. -""" - -MAX_LEN_SLACK = 400 # 슬랙 최대 메시지 길이 설정 - -split_messages = split_message(message, MAX_LEN_SLACK) - -# 나누어진 메시지 출력 -for i, part in enumerate(split_messages, 1): - print(f"Part {i}:") - print(part) - print("-" * 40) diff --git a/serverless.yml b/serverless.yml index 1732cd4..ca90051 100644 --- a/serverless.yml +++ b/serverless.yml @@ -1,54 +1,98 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/lalcebo/json-schema/master/serverless/reference.json -service: lambda-gurumi-ai-bot +service: lambda-gurumi-bot +frameworkVersion: "3" + provider: name: aws - region: us-east-1 runtime: python3.12 + # Keep arch aligned with the GitHub Actions Ubuntu runner so pip installs + # wheels that match the Lambda runtime (pydantic_core, etc.). Switching + # back to arm64 needs a Docker-based build (serverless-python-requirements + # dockerizePip) or a cross-platform wheel flag — deferred. + architecture: x86_64 + region: ${env:AWS_REGION, 'us-east-1'} stage: ${opt:stage, 'dev'} - architecture: arm64 memorySize: 5120 timeout: 90 environment: - BASE_NAME: gurumi-ai-bot - FOUNDATION_MODEL: ${env:FOUNDATION_MODEL, 'amazon.nova-lite-v1:0'} - AGENT_ID: - Fn::GetAtt: [BedrockAgent, AgentId] - AGENT_ALIAS_ID: - Fn::GetAtt: [BedrockAgentAlias, AgentAliasId] - iamRoleStatements: - - Effect: Allow - Action: - - dynamodb:GetItem - - dynamodb:PutItem - - dynamodb:Query - Resource: - - "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.BASE_NAME}-*" - - "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.BASE_NAME}-*/index/*" - - Effect: Allow - Action: - - bedrock:InvokeAgent - Resource: - - "arn:aws:bedrock:${self:provider.region}:*:agent-alias/*" + DYNAMODB_TABLE_NAME: ${self:service}-${sls:stage} + AWS_REGION_NAME: ${self:provider.region} + LLM_PROVIDER: ${env:LLM_PROVIDER, 'openai'} + LLM_MODEL: ${env:LLM_MODEL, 'gpt-4o-mini'} + IMAGE_PROVIDER: ${env:IMAGE_PROVIDER, 'openai'} + IMAGE_MODEL: ${env:IMAGE_MODEL, 'gpt-image-1'} + RESPONSE_LANGUAGE: ${env:RESPONSE_LANGUAGE, 'ko'} + AGENT_MAX_STEPS: ${env:AGENT_MAX_STEPS, '3'} + MAX_OUTPUT_TOKENS: ${env:MAX_OUTPUT_TOKENS, '4096'} + MAX_LEN_SLACK: ${env:MAX_LEN_SLACK, '3000'} + MAX_THROTTLE_COUNT: ${env:MAX_THROTTLE_COUNT, '100'} + MAX_HISTORY_CHARS: ${env:MAX_HISTORY_CHARS, '4000'} + BOT_CURSOR: ${env:BOT_CURSOR, ':robot_face:'} + ALLOWED_CHANNEL_IDS: ${env:ALLOWED_CHANNEL_IDS, ''} + ALLOWED_CHANNEL_MESSAGE: ${env:ALLOWED_CHANNEL_MESSAGE, ''} + SYSTEM_MESSAGE: ${env:SYSTEM_MESSAGE, ''} + LOG_LEVEL: ${env:LOG_LEVEL, 'INFO'} + DEFAULT_TIMEZONE: ${env:DEFAULT_TIMEZONE, 'Asia/Seoul'} + MAX_DOC_CHARS: ${env:MAX_DOC_CHARS, '20000'} + MAX_DOC_PAGES: ${env:MAX_DOC_PAGES, '50'} + MAX_DOC_BYTES: ${env:MAX_DOC_BYTES, '26214400'} + SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} + OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''} + XAI_API_KEY: ${env:XAI_API_KEY, ''} + TAVILY_API_KEY: ${env:TAVILY_API_KEY, ''} + iam: + role: + statements: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:Query + Resource: + - !GetAtt ConversationTable.Arn + - !Join ["", [!GetAtt ConversationTable.Arn, "/index/user-index"]] + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + - bedrock:Converse + - bedrock:ConverseStream + Resource: "*" + +plugins: + - serverless-python-requirements + +custom: + pythonRequirements: + slim: true + layer: false + noDeploy: + - pytest + - pytest-cov + - moto + - responses functions: mention: - handler: handler.lambda_handler + handler: app.lambda_handler events: - http: + path: slack/events method: post - path: /slack/events resources: Resources: - DynamoDBTable: + ConversationTable: Type: AWS::DynamoDB::Table Properties: - TableName: ${self:provider.environment.BASE_NAME}-${self:provider.stage} + TableName: ${self:service}-${sls:stage} AttributeDefinitions: - AttributeName: id AttributeType: S - AttributeName: user AttributeType: S + - AttributeName: expire_at + AttributeType: N KeySchema: - AttributeName: id KeyType: HASH @@ -57,282 +101,16 @@ resources: KeySchema: - AttributeName: user KeyType: HASH + - AttributeName: expire_at + KeyType: RANGE Projection: ProjectionType: KEYS_ONLY ProvisionedThroughput: ReadCapacityUnits: 5 WriteCapacityUnits: 5 - ProvisionedThroughput: - ReadCapacityUnits: 5 - WriteCapacityUnits: 5 TimeToLiveSpecification: AttributeName: expire_at Enabled: true - Tags: - - Key: Project - Value: ${self:provider.environment.BASE_NAME} - - S3Bucket: - Type: AWS::S3::Bucket - Properties: - BucketName: - Fn::Sub: ${self:provider.environment.BASE_NAME}-${AWS::AccountId} - Tags: - - Key: Project - Value: ${self:provider.environment.BASE_NAME} - - # --- RAG: S3 Vectors + Knowledge Base (uncomment to enable) --- - - # S3VectorBucket: - # Type: AWS::S3Vectors::VectorBucket - # Properties: - # VectorBucketName: - # Fn::Sub: ${self:provider.environment.BASE_NAME}-vectors-${AWS::AccountId} - # Tags: - # - Key: Project - # Value: ${self:provider.environment.BASE_NAME} - - # S3VectorIndex: - # Type: AWS::S3Vectors::Index - # DependsOn: S3VectorBucket - # Properties: - # VectorBucketName: - # Fn::Sub: ${self:provider.environment.BASE_NAME}-vectors-${AWS::AccountId} - # DataType: float32 - # Dimension: 1024 - # DistanceMetric: cosine - # MetadataConfiguration: - # NonFilterableMetadataKeys: - # - AMAZON_BEDROCK_TEXT - # - AMAZON_BEDROCK_METADATA - # Tags: - # - Key: Project - # Value: ${self:provider.environment.BASE_NAME} - - # BedrockKBRole: - # Type: AWS::IAM::Role - # Properties: - # RoleName: - # Fn::Sub: lambda-${self:provider.environment.BASE_NAME}-kb-role - # AssumeRolePolicyDocument: - # Version: "2012-10-17" - # Statement: - # - Effect: Allow - # Principal: - # Service: bedrock.amazonaws.com - # Action: sts:AssumeRole - # Condition: - # StringEquals: - # aws:SourceAccount: - # Ref: AWS::AccountId - # Policies: - # - PolicyName: BedrockKBPolicy - # PolicyDocument: - # Version: "2012-10-17" - # Statement: - # - Sid: S3VectorsAccess - # Effect: Allow - # Action: - # - s3vectors:PutVectors - # - s3vectors:GetVectors - # - s3vectors:DeleteVectors - # - s3vectors:QueryVectors - # - s3vectors:GetIndex - # Resource: - # Fn::GetAtt: [S3VectorIndex, IndexArn] - # - Sid: S3ListBucket - # Effect: Allow - # Action: - # - s3:ListBucket - # Resource: - # Fn::GetAtt: [S3Bucket, Arn] - # - Sid: S3GetObject - # Effect: Allow - # Action: - # - s3:GetObject - # Resource: - # Fn::Sub: - # - "${BucketArn}/*" - # - BucketArn: - # Fn::GetAtt: [S3Bucket, Arn] - # - Sid: BedrockInvokeModel - # Effect: Allow - # Action: - # - bedrock:InvokeModel - # Resource: - # Fn::Sub: arn:aws:bedrock:${self:provider.region}::foundation-model/amazon.titan-embed-text-v2:0 - # Tags: - # - Key: Project - # Value: ${self:provider.environment.BASE_NAME} - - # BedrockKnowledgeBase: - # Type: AWS::Bedrock::KnowledgeBase - # DependsOn: - # - S3VectorIndex - # - BedrockKBRole - # Properties: - # Name: - # Fn::Sub: ${self:provider.environment.BASE_NAME}-kb - # Description: Knowledge base for gurumi-ai-bot RAG - # RoleArn: - # Fn::GetAtt: [BedrockKBRole, Arn] - # KnowledgeBaseConfiguration: - # Type: VECTOR - # VectorKnowledgeBaseConfiguration: - # EmbeddingModelArn: - # Fn::Sub: arn:aws:bedrock:${self:provider.region}::foundation-model/amazon.titan-embed-text-v2:0 - # StorageConfiguration: - # Type: S3_VECTORS - # S3VectorsConfiguration: - # IndexArn: - # Fn::GetAtt: [S3VectorIndex, IndexArn] - # Tags: - # Project: ${self:provider.environment.BASE_NAME} - - # BedrockDataSource: - # Type: AWS::Bedrock::DataSource - # DependsOn: BedrockKnowledgeBase - # Properties: - # Name: ${self:provider.environment.BASE_NAME}-datasource - # KnowledgeBaseId: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseId] - # DataDeletionPolicy: RETAIN - # DataSourceConfiguration: - # Type: S3 - # S3Configuration: - # BucketArn: - # Fn::GetAtt: [S3Bucket, Arn] - # InclusionPrefixes: - # - "documents/" - # VectorIngestionConfiguration: - # ChunkingConfiguration: - # ChunkingStrategy: FIXED_SIZE - # FixedSizeChunkingConfiguration: - # MaxTokens: 300 - # OverlapPercentage: 20 - - # --- End RAG --- - - BedrockAgentRole: - Type: AWS::IAM::Role - Properties: - RoleName: - Fn::Sub: lambda-${self:provider.environment.BASE_NAME}-agent-role - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Principal: - Service: bedrock.amazonaws.com - Action: sts:AssumeRole - Condition: - StringEquals: - aws:SourceAccount: - Ref: AWS::AccountId - ArnLike: - aws:SourceArn: - Fn::Sub: arn:aws:bedrock:${self:provider.region}:${AWS::AccountId}:agent/* - Policies: - - PolicyName: BedrockAgentPolicy - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: InvokeModels - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: - - "arn:aws:bedrock:*::foundation-model/*" - - Fn::Sub: arn:aws:bedrock:*:${AWS::AccountId}:inference-profile/* - - Sid: InferenceProfiles - Effect: Allow - Action: - - bedrock:GetFoundationModel - - bedrock:GetInferenceProfile - Resource: "*" - # --- RAG: uncomment when Knowledge Base is enabled --- - # - Sid: KnowledgeBaseRetrieve - # Effect: Allow - # Action: - # - bedrock:Retrieve - # Resource: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseArn] - Tags: - - Key: Project - Value: ${self:provider.environment.BASE_NAME} - - BedrockAgent: - Type: AWS::Bedrock::Agent - DependsOn: - - BedrockAgentRole - # - BedrockKnowledgeBase # uncomment when KB is enabled - Properties: - AgentName: ${self:provider.environment.BASE_NAME} - Description: AWSKRUG AI assistant gurumi - FoundationModel: ${self:provider.environment.FOUNDATION_MODEL} - AgentResourceRoleArn: - Fn::GetAtt: [BedrockAgentRole, Arn] - Instruction: >- - You are Gurumi (구루미), the friendly and professional AI assistant of AWSKRUG (AWS Korea User Group). - Answer every user question directly using your own general knowledge. - You have no external tools, APIs, or knowledge bases available; never claim a request is out of scope, - out of domain, or beyond your tools - always attempt a helpful answer from your internal knowledge. - If you truly do not know something, say so honestly. Respond in natural, easy-to-understand Korean. - IdleSessionTTLInSeconds: 600 - AutoPrepare: true - # Override default ORCHESTRATION template to remove outOfDomain refusal behavior - # (Nova Pro with no actions/KB refuses everything under the default template). - PromptOverrideConfiguration: - PromptConfigurations: - - PromptType: ORCHESTRATION - PromptCreationMode: OVERRIDDEN - PromptState: ENABLED - BasePromptTemplate: | - {"system":"\n\n\nAgent Description:\n$instruction$\n\nAlways follow these instructions:\n- You have NO external actions, tools, APIs, or knowledge bases. Never call any action.\n- Answer every user question directly using your own general knowledge.\n- NEVER say a request is out of scope, out of domain, or beyond your tools. Always attempt a helpful answer.\n- If you truly do not know the factual answer, say so honestly in Korean, but still try to help.\n- Always generate a Thought within tags before responding. Keep it brief.\n- Provide your final response within tags. Respond in natural Korean.\n$knowledge_base_additional_guideline$\n$memory_guideline$\n$memory_content$\n$memory_action_guideline$\n$prompt_session_attributes$\n","messages":[{"role":"user","content":[{"text":"$question$"}]},{"role":"assistant","content":[{"text":"$agent_scratchpad$"}]},{"role":"assistant","content":[{"text":"Thought: \n"}]}]} - InferenceConfiguration: - # Only Temperature - Claude Sonnet rejects Temperature+TopP together; - # Nova Pro is also fine with Temperature alone. - Temperature: 0.3 - MaximumLength: 2048 - StopSequences: - - "" - ParserMode: DEFAULT - # --- RAG: uncomment when Knowledge Base is enabled --- - # KnowledgeBases: - # - KnowledgeBaseId: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseId] - # Description: RAG knowledge base for document retrieval - # KnowledgeBaseState: ENABLED - Tags: - Project: ${self:provider.environment.BASE_NAME} - - BedrockAgentAlias: - Type: AWS::Bedrock::AgentAlias - DependsOn: BedrockAgent - Properties: - AgentId: - Fn::GetAtt: [BedrockAgent, AgentId] - AgentAliasName: live - Description: Production alias for gurumi agent - Tags: - Project: ${self:provider.environment.BASE_NAME} - - Outputs: - AgentId: - Value: - Fn::GetAtt: [BedrockAgent, AgentId] - AgentAliasId: - Value: - Fn::GetAtt: [BedrockAgentAlias, AgentAliasId] - # --- RAG: uncomment when Knowledge Base is enabled --- - # KnowledgeBaseId: - # Value: - # Fn::GetAtt: [BedrockKnowledgeBase, KnowledgeBaseId] - # DataSourceId: - # Value: - # Fn::GetAtt: [BedrockDataSource, DataSourceId] - -plugins: - - serverless-python-requirements - - serverless-dotenv-plugin + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..4b5d1aa --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Lambda Slack bot package.""" diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..97d9786 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,185 @@ +"""Agent loop using native LLM function calling. + +No more JSON-in-prompt tool orchestration: we pass tool specs directly to +the LLM and let the provider emit structured tool_calls. The loop ends +when the LLM stops requesting tools (stop_reason == "end_turn") or we hit +`max_steps`. Duplicate tool calls (same name+args) within the loop are +short-circuited to prevent runaway loops. +""" +from __future__ import annotations + +import hashlib +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Callable + +from src.llm import LLMProvider, ToolCall +from src.logging_utils import log_event +from src.tools import ToolContext, ToolExecutor, ToolRegistry + +logger = logging.getLogger(__name__) + + +@dataclass +class AgentResult: + text: str + image_url: str | None = None + steps: int = 0 + tool_calls_count: int = 0 + token_usage: dict[str, int] = field(default_factory=dict) + + +class SlackMentionAgent: + def __init__( + self, + llm: LLMProvider, + context: ToolContext, + registry: ToolRegistry, + max_steps: int, + tool_executor: ToolExecutor | None = None, + response_language: str = "ko", + system_message: str | None = None, + history: list[dict[str, Any]] | None = None, + on_stream: Callable[[str], None] | None = None, + on_step: Callable[[int, str, dict[str, Any]], None] | None = None, + max_output_tokens: int = 4096, + ): + self.llm = llm + self.context = context + self.registry = registry + self.max_steps = max_steps + self.executor = tool_executor or ToolExecutor(context, registry) + self.response_language = response_language + self.system_message = system_message + self.history = history or [] + self.on_stream = on_stream + # on_step(step_num, phase, detail) — phases: "tool_use", "tool_result", "compose" + self.on_step = on_step + self.max_output_tokens = max_output_tokens + + def run(self, user_message: str) -> AgentResult: + system = self._build_system_prompt() + messages: list[dict[str, Any]] = [*self.history, {"role": "user", "content": user_message}] + seen_calls: set[str] = set() + image_url: str | None = None + total_usage = {"input": 0, "output": 0} + tool_calls_total = 0 + steps = 0 + + for step in range(self.max_steps): + steps = step + 1 + # Pass on_stream as on_delta so the provider can stream content + # tokens live while still returning accumulated tool_calls. When + # the model starts a tool_call the provider stops forwarding + # content to avoid leaking the pre-tool preamble into the reply. + result = self.llm.chat( + system, + messages, + tools=self.registry.specs(), + max_tokens=self.max_output_tokens, + on_delta=self.on_stream, + ) + total_usage["input"] += result.token_usage.get("input", 0) + total_usage["output"] += result.token_usage.get("output", 0) + log_event(logger, "llm.hop", step=steps, stop=result.stop_reason, tool_calls=len(result.tool_calls)) + + if not result.tool_calls: + self._notify_step(steps, "compose", {}) + # If on_stream was provided, the content was already streamed + # during this hop — don't pay for a second LLM call to re-do it. + return AgentResult( + text=(result.content or "").strip(), + image_url=image_url, + steps=steps, + tool_calls_count=tool_calls_total, + token_usage=total_usage, + ) + + tool_names = [c.name for c in result.tool_calls] + self._notify_step(steps, "tool_use", {"tools": tool_names}) + + messages.append( + { + "role": "assistant", + "content": result.content or "", + "tool_calls": [ + {"id": c.id, "name": c.name, "arguments": c.arguments} for c in result.tool_calls + ], + } + ) + + for call in result.tool_calls: + tool_calls_total += 1 + signature = self._call_signature(call) + if signature in seen_calls: + tool_result = {"ok": False, "error": "duplicate call skipped"} + else: + seen_calls.add(signature) + tool_result = self.executor.execute(call) + log_event(logger, "tool.result", step=steps, tool=call.name, ok=tool_result.get("ok", False)) + + self._notify_step( + steps, + "tool_result", + {"tool": call.name, "ok": bool(tool_result.get("ok")), "error": tool_result.get("error")}, + ) + + if call.name == "generate_image" and tool_result.get("ok"): + permalink = (tool_result.get("result") or {}).get("permalink") + if permalink: + image_url = permalink + + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": json.dumps(tool_result, ensure_ascii=False), + } + ) + + # max_steps reached — force one final compose without tools. + self._notify_step(steps, "compose", {"max_steps_hit": True}) + final_text = self._compose_without_tools(system, messages) + return AgentResult(text=final_text, image_url=image_url, steps=steps, tool_calls_count=tool_calls_total, token_usage=total_usage) + + # ------------------------------------------------------------------ # + + def _build_system_prompt(self) -> str: + base = ( + self.system_message + or "You are an assistant for Slack mention requests. Plan work, call tools when needed, and provide concise helpful answers." + ) + # Slack-specific rendering rules. + slack_rules = ( + "When you call the `generate_image` tool, the generated image is " + "already uploaded inline into the Slack thread. Do NOT repeat the " + "image URL or permalink in your text reply — just describe or " + "caption the image briefly. The user sees the picture attached " + "directly; a URL line is duplicate noise." + ) + return f"{base}\n\n{slack_rules}\n\nRespond in language: {self.response_language}." + + @staticmethod + def _call_signature(call: ToolCall) -> str: + args_blob = json.dumps(call.arguments or {}, sort_keys=True, ensure_ascii=False) + return f"{call.name}:{hashlib.sha1(args_blob.encode()).hexdigest()[:12]}" + + def _compose_without_tools(self, system: str, messages: list[dict[str, Any]]) -> str: + """Force a final answer when max_steps is reached — no tools permitted.""" + directive = ( + "Provide the final answer now. Do not request any more tools; summarize based on prior observations." + ) + messages = [*messages, {"role": "user", "content": directive}] + if self.on_stream: + return self.llm.stream_chat(system, messages, on_delta=self.on_stream, max_tokens=self.max_output_tokens) + result = self.llm.chat(system, messages, tools=None, max_tokens=self.max_output_tokens) + return (result.content or "").strip() + + def _notify_step(self, step: int, phase: str, detail: dict[str, Any]) -> None: + if not self.on_step: + return + try: + self.on_step(step, phase, detail) + except Exception as exc: # noqa: BLE001 + logger.debug("on_step callback failed: %s", exc) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..61e03c5 --- /dev/null +++ b/src/config.py @@ -0,0 +1,144 @@ +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _load_env_local() -> None: + """Load .env.local from the project root when running locally.""" + try: + from dotenv import load_dotenv + + env_path = Path(__file__).resolve().parent.parent / ".env.local" + if env_path.exists(): + load_dotenv(env_path, override=False) + except ImportError: + pass + + +_load_env_local() + + +_VALID_LANGUAGES = {"ko", "en"} +_VALID_PROVIDERS = {"openai", "bedrock", "xai"} + + +def _int_env(name: str, default: int, minimum: int = 1) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + try: + value = int(raw) + except ValueError: + logger.warning("invalid int for %s=%r, using default=%d", name, raw, default) + return default + if value < minimum: + logger.warning("%s=%d below minimum %d, using minimum", name, value, minimum) + return minimum + return value + + +def _tz_env(name: str, default: str) -> str: + """Return a validated IANA timezone name, warning + falling back on bad input.""" + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + raw = os.getenv(name, "").strip() + if not raw: + return default + try: + ZoneInfo(raw) + except ZoneInfoNotFoundError: + logger.warning("invalid %s=%r, falling back to %s", name, raw, default) + return default + return raw + + +def _list_env(name: str) -> list[str]: + raw = os.getenv(name, "").strip() + if not raw or raw.lower() == "none": + return [] + return [part.strip() for part in raw.split(",") if part.strip()] + + +def _enum_env(name: str, default: str, valid: set[str]) -> str: + value = os.getenv(name, default).strip().lower() + if value not in valid: + logger.warning("invalid %s=%r, falling back to %s", name, value, default) + return default + return value + + +@dataclass(frozen=True) +class Settings: + slack_bot_token: str + slack_signing_secret: str + llm_provider: str + llm_model: str + image_provider: str + image_model: str + agent_max_steps: int + response_language: str + dynamodb_table_name: str + aws_region: str + allowed_channel_ids: list[str] = field(default_factory=list) + allowed_channel_message: str = "" + max_len_slack: int = 3000 + max_throttle_count: int = 100 + max_history_chars: int = 4000 + max_output_tokens: int = 4096 + bot_cursor: str = ":robot_face:" + system_message: str | None = None + tavily_api_key: str | None = None + xai_api_key: str | None = None + log_level: str = "INFO" + default_timezone: str = "Asia/Seoul" + max_doc_chars: int = 20_000 + max_doc_pages: int = 50 + max_doc_bytes: int = 25 * 1024 * 1024 + + @classmethod + def from_env(cls) -> "Settings": + llm_provider = _enum_env("LLM_PROVIDER", "openai", _VALID_PROVIDERS) + image_provider = _enum_env( + "IMAGE_PROVIDER", + os.getenv("LLM_PROVIDER", "openai").strip().lower() or "openai", + _VALID_PROVIDERS, + ) + response_language = _enum_env("RESPONSE_LANGUAGE", "ko", _VALID_LANGUAGES) + system_message = os.getenv("SYSTEM_MESSAGE", "").strip() or None + tavily_key = os.getenv("TAVILY_API_KEY", "").strip() or None + xai_key = os.getenv("XAI_API_KEY", "").strip() or None + return cls( + slack_bot_token=os.getenv("SLACK_BOT_TOKEN", "").strip(), + slack_signing_secret=os.getenv("SLACK_SIGNING_SECRET", "").strip(), + llm_provider=llm_provider, + llm_model=os.getenv("LLM_MODEL", "gpt-4o-mini").strip(), + image_provider=image_provider, + image_model=os.getenv("IMAGE_MODEL", "gpt-image-1").strip(), + agent_max_steps=_int_env("AGENT_MAX_STEPS", 3, minimum=1), + response_language=response_language, + dynamodb_table_name=os.getenv("DYNAMODB_TABLE_NAME", "lambda-gurumi-bot-dev").strip(), + aws_region=os.getenv("AWS_REGION", "us-east-1").strip(), + allowed_channel_ids=_list_env("ALLOWED_CHANNEL_IDS"), + allowed_channel_message=os.getenv("ALLOWED_CHANNEL_MESSAGE", "").strip(), + max_len_slack=_int_env("MAX_LEN_SLACK", 2000, minimum=500), + max_throttle_count=_int_env("MAX_THROTTLE_COUNT", 100, minimum=1), + max_history_chars=_int_env("MAX_HISTORY_CHARS", 4000, minimum=500), + max_output_tokens=_int_env("MAX_OUTPUT_TOKENS", 4096, minimum=256), + bot_cursor=os.getenv("BOT_CURSOR", ":robot_face:").strip() or ":robot_face:", + system_message=system_message, + tavily_api_key=tavily_key, + xai_api_key=xai_key, + log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", + default_timezone=_tz_env("DEFAULT_TIMEZONE", "Asia/Seoul"), + max_doc_chars=_int_env("MAX_DOC_CHARS", 20_000, minimum=1000), + max_doc_pages=_int_env("MAX_DOC_PAGES", 50, minimum=1), + max_doc_bytes=_int_env("MAX_DOC_BYTES", 25 * 1024 * 1024, minimum=64 * 1024), + ) + + def require_slack_credentials(self) -> None: + """Lazy validation — call from request handlers, not at import time.""" + if not self.slack_bot_token or not self.slack_signing_secret: + raise RuntimeError("SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET are required") diff --git a/src/dedup.py b/src/dedup.py new file mode 100644 index 0000000..c42365b --- /dev/null +++ b/src/dedup.py @@ -0,0 +1,136 @@ +"""DynamoDB-backed idempotency and thread conversation memory. + +Single table, partition key `id`. Two key prefixes share the table: +- `dedup:{client_msg_id}` — one-shot reservation for request deduplication. +- `ctx:{thread_ts}` — conversation history for thread memory. + +A GSI on `user` + `expire_at` enables per-user active-request counting +for throttling. +""" +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from typing import Any + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + + +@dataclass +class _BaseStore: + table_name: str + region: str + _table: Any = None + + def _get_table(self) -> Any: + if self._table is None: + self._table = boto3.resource("dynamodb", region_name=self.region).Table(self.table_name) + return self._table + + +class DedupStore(_BaseStore): + """Atomic reservation for Slack retry deduplication + user throttle count.""" + + GSI_NAME = "user-index" + + def reserve(self, event_key: str, user: str = "system", ttl_seconds: int = 3600) -> bool: + """Return True if reservation succeeds, False if already reserved. + + Uses ConditionExpression=attribute_not_exists(id) for atomicity — no + get-then-put race window. + """ + table = self._get_table() + expire_at = int(time.time()) + ttl_seconds + try: + table.put_item( + Item={"id": f"dedup:{event_key}", "user": user, "expire_at": expire_at}, + ConditionExpression="attribute_not_exists(id)", + ) + return True + except ClientError as exc: + if exc.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + return False + logger.warning("dedup reserve failed: %s", exc) + raise + + def count_user_active(self, user: str) -> int: + """Number of non-expired reservations for a user (throttle check).""" + if not user: + return 0 + table = self._get_table() + now = int(time.time()) + try: + res = table.query( + IndexName=self.GSI_NAME, + KeyConditionExpression="#u = :u AND expire_at > :now", + ExpressionAttributeNames={"#u": "user"}, + ExpressionAttributeValues={":u": user, ":now": now}, + Select="COUNT", + ) + return int(res.get("Count", 0)) + except ClientError as exc: + logger.warning("count_user_active failed: %s", exc) + return 0 + + +class ConversationStore(_BaseStore): + """Thread conversation history with TTL.""" + + def get(self, thread_ts: str) -> list[dict[str, Any]]: + if not thread_ts: + return [] + table = self._get_table() + try: + res = table.get_item(Key={"id": f"ctx:{thread_ts}"}) + except ClientError as exc: + logger.warning("conversation get failed: %s", exc) + return [] + item = res.get("Item") + if not item: + return [] + raw = item.get("conversation") or "[]" + try: + messages = json.loads(raw) + return messages if isinstance(messages, list) else [] + except json.JSONDecodeError: + logger.warning("malformed conversation blob for %s", thread_ts) + return [] + + def put( + self, + thread_ts: str, + user: str, + messages: list[dict[str, Any]], + ttl_seconds: int = 3600, + max_chars: int = 4000, + ) -> None: + if not thread_ts: + return + trimmed = self.truncate_to_chars(messages, max_chars) + table = self._get_table() + try: + table.put_item( + Item={ + "id": f"ctx:{thread_ts}", + "user": user or "unknown", + "expire_at": int(time.time()) + ttl_seconds, + "conversation": json.dumps(trimmed, ensure_ascii=False), + } + ) + except ClientError as exc: + logger.warning("conversation put failed: %s", exc) + + @staticmethod + def truncate_to_chars(messages: list[dict[str, Any]], max_chars: int) -> list[dict[str, Any]]: + """Drop oldest messages until total serialized size <= max_chars.""" + if not messages: + return [] + out = list(messages) + while out and len(json.dumps(out, ensure_ascii=False)) > max_chars: + out.pop(0) + return out diff --git a/src/llm.py b/src/llm.py new file mode 100644 index 0000000..a0a74dd --- /dev/null +++ b/src/llm.py @@ -0,0 +1,818 @@ +"""LLM provider abstraction with native function calling. + +Three providers: +- OpenAIProvider: OpenAI chat completions with `tools=`, vision, image generation. +- XAIProvider: xAI (Grok) — OpenAI-wire compatible at https://api.x.ai/v1. + Shares `_OpenAICompatProvider` machinery with OpenAI; differs in image kwargs + (omits `size`, forces `response_format=b64_json`) and token params + (always `max_tokens` + `temperature`). +- BedrockProvider: family-routed. Anthropic Claude uses Messages API with tools; + Amazon Nova uses Converse API with toolConfig; others fall back to plain text. + Accepts both bare model IDs and `us./eu./apac./global.` inference-profile IDs. + +All providers implement the LLMProvider protocol so the Agent loop is +provider-agnostic. `_CompositeProvider` wraps two providers when text and +image providers differ (e.g., OpenAI text + xAI image). +""" +from __future__ import annotations + +import base64 +import json +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Literal, Protocol + +import boto3 + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Types +# --------------------------------------------------------------------------- # + +ToolSpec = dict[str, Any] # {"name","description","parameters"(JSON Schema)} + + +@dataclass +class ToolCall: + id: str + name: str + arguments: dict[str, Any] + + +@dataclass +class LLMResult: + content: str + tool_calls: list[ToolCall] = field(default_factory=list) + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] = "end_turn" + token_usage: dict[str, int] = field(default_factory=dict) + + +class LLMProvider(Protocol): + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: ... + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: ... + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: ... + + def generate_image(self, prompt: str) -> bytes: ... + + +# --------------------------------------------------------------------------- # +# Retry helper +# --------------------------------------------------------------------------- # + +_RETRYABLE_BEDROCK = {"ThrottlingException", "ServiceQuotaExceededException", "ModelTimeoutException"} + + +def _with_retry(fn: Callable[[], Any], label: str, attempts: int = 3) -> Any: + delay = 1.0 + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return fn() + except Exception as exc: # noqa: BLE001 + last_exc = exc + code = getattr(getattr(exc, "response", None), "get", lambda _k, _d=None: None)("Error", {}).get("Code") if hasattr(exc, "response") else None + if code in _RETRYABLE_BEDROCK and attempt < attempts - 1: + logger.warning("%s retryable (%s), backoff %.1fs", label, code, delay) + time.sleep(delay) + delay *= 2 + continue + raise + if last_exc: + raise last_exc + + +# --------------------------------------------------------------------------- # +# OpenAI +# --------------------------------------------------------------------------- # + + +_OPENAI_NEW_GENERATION_PREFIXES = ("gpt-5", "o1", "o3", "o4") + + +def _is_new_gen_openai(model: str) -> bool: + """Newer OpenAI models (gpt-5, o1/o3/o4 reasoning) use `max_completion_tokens` + and disallow `temperature` overrides.""" + return any(model.startswith(p) for p in _OPENAI_NEW_GENERATION_PREFIXES) + + +# --------------------------------------------------------------------------- # +# Module-level helpers shared between OpenAI-compatible providers (OpenAI, xAI) +# --------------------------------------------------------------------------- # + + +def _to_openai_wire_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate canonical messages (our agent's shape) to OpenAI's wire shape.""" + out: list[dict[str, Any]] = [] + for msg in messages: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + out.append( + { + "role": "assistant", + "content": msg.get("content") or None, + "tool_calls": [ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": json.dumps(tc.get("arguments") or {}, ensure_ascii=False), + }, + } + for tc in msg["tool_calls"] + ], + } + ) + else: + out.append(msg) + return out + + +def _build_openai_tools_payload(tools: list[ToolSpec]) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }, + } + for t in tools + ] + + +def _map_openai_finish_reason(finish: str | None) -> Literal["end_turn", "tool_use", "max_tokens", "other"]: + if finish == "tool_calls": + return "tool_use" + if finish == "length": + return "max_tokens" + if finish in {"stop", None}: + return "end_turn" + return "other" + + +def _extract_openai_usage(usage_obj) -> dict[str, int]: + if not usage_obj: + return {} + return { + "input": getattr(usage_obj, "prompt_tokens", 0) or 0, + "output": getattr(usage_obj, "completion_tokens", 0) or 0, + } + + +def _parse_openai_completion(completion) -> LLMResult: + choice = completion.choices[0] + msg = choice.message + tool_calls: list[ToolCall] = [] + for call in (msg.tool_calls or []): + try: + args = json.loads(call.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall(id=call.id, name=call.function.name, arguments=args)) + + return LLMResult( + content=msg.content or "", + tool_calls=tool_calls, + stop_reason=_map_openai_finish_reason(choice.finish_reason), + token_usage=_extract_openai_usage(getattr(completion, "usage", None)), + ) + + +def _consume_openai_stream(stream, on_delta: Callable[[str], None]) -> LLMResult: + """Drain an OpenAI-compatible chat completion stream. + + Stops forwarding content to `on_delta` once a tool_calls delta arrives — + any trailing commentary would otherwise leak into the final user reply. + tool_calls chunks are accumulated by index and returned as ToolCall list. + """ + content_parts: list[str] = [] + tool_calls_accum: dict[int, dict[str, Any]] = {} + saw_tool_calls = False + finish_reason: str | None = None + usage_obj = None + + for chunk in stream: + usage_obj = getattr(chunk, "usage", None) or usage_obj + if not chunk.choices: + continue + choice = chunk.choices[0] + delta = choice.delta + if getattr(delta, "tool_calls", None): + saw_tool_calls = True + for tc in delta.tool_calls: + idx = tc.index + slot = tool_calls_accum.setdefault(idx, {"id": None, "name": "", "arguments": ""}) + if getattr(tc, "id", None): + slot["id"] = tc.id + fn = getattr(tc, "function", None) + if fn is not None: + if getattr(fn, "name", None): + slot["name"] += fn.name + if getattr(fn, "arguments", None): + slot["arguments"] += fn.arguments + if getattr(delta, "content", None): + content_parts.append(delta.content) + if not saw_tool_calls: + on_delta(delta.content) + if getattr(choice, "finish_reason", None): + finish_reason = choice.finish_reason + + tool_calls: list[ToolCall] = [] + for idx in sorted(tool_calls_accum): + slot = tool_calls_accum[idx] + try: + args = json.loads(slot["arguments"] or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall(id=slot["id"] or "", name=slot["name"], arguments=args)) + + return LLMResult( + content="".join(content_parts), + tool_calls=tool_calls, + stop_reason=_map_openai_finish_reason(finish_reason), + token_usage=_extract_openai_usage(usage_obj), + ) + + +class _OpenAICompatProvider: + """Shared machinery for any OpenAI-wire-compatible chat/vision/image API. + + Subclasses set BASE_URL / API_KEY_ENV_VAR and override small hooks + (`_token_params`, `_image_generate_kwargs`). The heavy lifting — + payload assembly, streaming, tool_calls parsing — lives on this base + and on the module-level helpers above. + """ + + BASE_URL: str | None = None # None = OpenAI default + API_KEY_ENV_VAR: str = "OPENAI_API_KEY" + + def __init__(self, model: str, image_model: str, api_key: str | None = None): + self.model = model + self.image_model = image_model + self._api_key = api_key + self._client = None + + def _get_client(self): + if self._client is None: + from openai import OpenAI + + kwargs: dict[str, Any] = {} + if self.BASE_URL: + kwargs["base_url"] = self.BASE_URL + if self._api_key: + kwargs["api_key"] = self._api_key + self._client = OpenAI(**kwargs) + return self._client + + # -- hooks -------------------------------------------------------------- # + + def _token_params(self, max_tokens: int) -> dict[str, Any]: + """Default: OpenAI legacy models use max_tokens+temperature.""" + return {"max_tokens": max_tokens, "temperature": 0.2} + + def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: + """Default OpenAI (dall-e / gpt-image-1) image call kwargs.""" + kwargs: dict[str, Any] = { + "model": self.image_model, + "prompt": prompt, + "size": "1024x1024", + } + # gpt-image-1 rejects `response_format` (b64 is the default); only legacy + # DALL-E models need the explicit flag. + if self.image_model.startswith("dall-e"): + kwargs["response_format"] = "b64_json" + return kwargs + + # -- LLMProvider surface ----------------------------------------------- # + + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: + client = self._get_client() + payload: dict[str, Any] = { + "model": self.model, + "messages": [{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], + **self._token_params(max_tokens), + } + if tools: + payload["tools"] = _build_openai_tools_payload(tools) + payload["tool_choice"] = "auto" + + if on_delta is None: + completion = client.chat.completions.create(**payload) + return _parse_openai_completion(completion) + + payload = {**payload, "stream": True, "stream_options": {"include_usage": True}} + stream = client.chat.completions.create(**payload) + return _consume_openai_stream(stream, on_delta) + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: + client = self._get_client() + stream = client.chat.completions.create( + model=self.model, + messages=[{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], + stream=True, + **self._token_params(max_tokens), + ) + full = "" + for chunk in stream: + delta = chunk.choices[0].delta.content if chunk.choices else None + if delta: + full += delta + on_delta(delta) + return full + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + client = self._get_client() + encoded = base64.b64encode(image_bytes).decode("utf-8") + completion = client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image for a Slack conversation."}, + {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{encoded}"}}, + ], + } + ], + ) + return completion.choices[0].message.content or "" + + def generate_image(self, prompt: str) -> bytes: + client = self._get_client() + response = client.images.generate(**self._image_generate_kwargs(prompt)) + return base64.b64decode(response.data[0].b64_json) + + +class OpenAIProvider(_OpenAICompatProvider): + BASE_URL = None # default OpenAI endpoint + API_KEY_ENV_VAR = "OPENAI_API_KEY" + + def _token_params(self, max_tokens: int) -> dict[str, Any]: + # Newer OpenAI reasoning models only accept max_completion_tokens and + # reject `temperature`. Legacy chat models still use max_tokens. + if _is_new_gen_openai(self.model): + return {"max_completion_tokens": max_tokens} + return {"max_tokens": max_tokens, "temperature": 0.2} + + +class XAIProvider(_OpenAICompatProvider): + """xAI (Grok) — OpenAI-wire compatible, different base URL and image params. + + Models: + text: grok-4-1-fast-reasoning, grok-4.20-0309-reasoning, ... + image: grok-imagine-image, grok-imagine-image-pro + + Differences from OpenAI that matter here: + - `images.generate` rejects `size` (uses `aspect_ratio`/`resolution`). + We omit `size` and request `response_format=b64_json` so we can + decode bytes locally, matching the rest of the pipeline. + - All current grok chat models accept `max_tokens` + `temperature` + the classic way — no `max_completion_tokens` split. + """ + + BASE_URL = "https://api.x.ai/v1" + API_KEY_ENV_VAR = "XAI_API_KEY" + + def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: + return { + "model": self.image_model, + "prompt": prompt, + "n": 1, + "response_format": "b64_json", + } + + +# --------------------------------------------------------------------------- # +# Bedrock +# --------------------------------------------------------------------------- # + + +_INFERENCE_PROFILE_PREFIXES = ("us.", "eu.", "apac.", "global.") + + +def _strip_inference_profile_prefix(model_id: str) -> str: + """Return the bare family id from a Bedrock model or inference-profile id. + + Inference profile IDs prefix the family with a region routing hint, e.g. + `us.anthropic.claude-haiku-4-5-20251001-v1:0`. For family-level routing + ("is this a Claude? a Nova? Titan?") we care about the bare portion. + """ + for p in _INFERENCE_PROFILE_PREFIXES: + if model_id.startswith(p): + return model_id[len(p):] + return model_id + + +class BedrockProvider: + def __init__(self, model: str, image_model: str, region: str): + self.model = model + self.image_model = image_model + self.region = region + self._client = None + + def _get_client(self): + if self._client is None: + self._client = boto3.client("bedrock-runtime", region_name=self.region) + return self._client + + @property + def _text_family(self) -> str: + return _strip_inference_profile_prefix(self.model) + + @property + def _image_family(self) -> str: + return _strip_inference_profile_prefix(self.image_model) + + # -- text / tool use ---------------------------------------------------- # + + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: + # Bedrock tool_use streaming is not yet implemented in this provider; + # accept the on_delta parameter for API compatibility but use the + # blocking path, then emit the final content as a single delta so + # callers still receive *something* through the streaming channel. + family = self._text_family + if family.startswith("anthropic.claude"): + result = self._claude_chat(system, messages, tools, max_tokens) + elif family.startswith("amazon.nova"): + result = self._nova_chat(system, messages, tools, max_tokens) + else: + result = self._claude_chat(system, messages, None, max_tokens) + if on_delta is not None and result.content and not result.tool_calls: + on_delta(result.content) + return result + + def _claude_chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None, + max_tokens: int, + ) -> LLMResult: + body: dict[str, Any] = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system, + "messages": self._to_anthropic_messages(messages), + } + if tools: + body["tools"] = [ + {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} + for t in tools + ] + + client = self._get_client() + response = _with_retry( + lambda: client.invoke_model(modelId=self.model, body=json.dumps(body)), + label="bedrock.invoke_model", + ) + payload = json.loads(response["body"].read()) + + content_text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in payload.get("content", []): + if block.get("type") == "text": + content_text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_use": + tool_calls.append( + ToolCall( + id=block.get("id", str(uuid.uuid4())), + name=block.get("name", ""), + arguments=block.get("input", {}) or {}, + ) + ) + + stop_reason_raw = payload.get("stop_reason", "end_turn") + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] + if stop_reason_raw == "tool_use": + stop_reason = "tool_use" + elif stop_reason_raw == "max_tokens": + stop_reason = "max_tokens" + elif stop_reason_raw == "end_turn": + stop_reason = "end_turn" + else: + stop_reason = "other" + + usage = payload.get("usage") or {} + return LLMResult( + content="".join(content_text_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + token_usage={"input": usage.get("input_tokens", 0) or 0, "output": usage.get("output_tokens", 0) or 0}, + ) + + def _nova_chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None, + max_tokens: int, + ) -> LLMResult: + client = self._get_client() + payload: dict[str, Any] = { + "modelId": self.model, + "system": [{"text": system}], + "messages": self._to_nova_messages(messages), + "inferenceConfig": {"maxTokens": max_tokens, "temperature": 0.2}, + } + if tools: + payload["toolConfig"] = { + "tools": [ + {"toolSpec": {"name": t["name"], "description": t["description"], "inputSchema": {"json": t["parameters"]}}} + for t in tools + ] + } + + response = _with_retry(lambda: client.converse(**payload), label="bedrock.converse") + out_msg = response.get("output", {}).get("message", {}) + content_text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in out_msg.get("content", []): + if "text" in block: + content_text_parts.append(block["text"]) + elif "toolUse" in block: + tu = block["toolUse"] + tool_calls.append( + ToolCall(id=tu.get("toolUseId") or str(uuid.uuid4()), name=tu.get("name", ""), arguments=tu.get("input", {}) or {}) + ) + + stop_reason_raw = response.get("stopReason", "end_turn") + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] + if stop_reason_raw == "tool_use": + stop_reason = "tool_use" + elif stop_reason_raw == "max_tokens": + stop_reason = "max_tokens" + elif stop_reason_raw == "end_turn": + stop_reason = "end_turn" + else: + stop_reason = "other" + + usage = response.get("usage") or {} + return LLMResult( + content="".join(content_text_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + token_usage={"input": usage.get("inputTokens", 0) or 0, "output": usage.get("outputTokens", 0) or 0}, + ) + + # -- streaming --------------------------------------------------------- # + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: + # Bedrock streaming implementation: Claude Messages stream or Converse stream. + client = self._get_client() + full = "" + family = self._text_family + if family.startswith("anthropic.claude"): + body = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system, + "messages": self._to_anthropic_messages(messages), + } + response = client.invoke_model_with_response_stream(modelId=self.model, body=json.dumps(body)) + for event in response.get("body", []): + chunk = event.get("chunk", {}) + if not chunk: + continue + payload = json.loads(chunk.get("bytes", b"{}")) + if payload.get("type") == "content_block_delta": + delta = (payload.get("delta") or {}).get("text") or "" + if delta: + full += delta + on_delta(delta) + return full + + # Nova Converse stream + response = client.converse_stream( + modelId=self.model, + system=[{"text": system}], + messages=self._to_nova_messages(messages), + inferenceConfig={"maxTokens": max_tokens, "temperature": 0.2}, + ) + for event in response.get("stream", []): + cbd = event.get("contentBlockDelta") + if cbd: + delta = (cbd.get("delta") or {}).get("text") or "" + if delta: + full += delta + on_delta(delta) + return full + + # -- vision / image ----------------------------------------------------- # + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + encoded = base64.b64encode(image_bytes).decode("utf-8") + body = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 512, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image for a Slack conversation."}, + {"type": "image", "source": {"type": "base64", "media_type": mime_type, "data": encoded}}, + ], + } + ], + } + client = self._get_client() + response = client.invoke_model(modelId=self.model, body=json.dumps(body)) + payload = json.loads(response["body"].read()) + for block in payload.get("content", []): + if block.get("type") == "text": + return block.get("text", "") + return "" + + def generate_image(self, prompt: str) -> bytes: + body = self._build_image_body(prompt) + client = self._get_client() + response = client.invoke_model(modelId=self.image_model, body=json.dumps(body)) + payload = json.loads(response["body"].read()) + return self._extract_image_bytes(payload) + + def _build_image_body(self, prompt: str) -> dict[str, Any]: + family = self._image_family + if family.startswith("amazon.titan-image") or family.startswith("amazon.nova-canvas"): + return { + "taskType": "TEXT_IMAGE", + "textToImageParams": {"text": prompt}, + "imageGenerationConfig": {"numberOfImages": 1, "quality": "standard", "height": 1024, "width": 1024}, + } + if family.startswith("stability."): + return {"text_prompts": [{"text": prompt}], "cfg_scale": 7, "steps": 30, "seed": 0} + raise ValueError(f"unsupported Bedrock image model: {self.image_model}") + + def _extract_image_bytes(self, payload: dict[str, Any]) -> bytes: + if "images" in payload and payload["images"]: + return base64.b64decode(payload["images"][0]) + if "artifacts" in payload and payload["artifacts"]: + return base64.b64decode(payload["artifacts"][0]["base64"]) + raise ValueError("no image returned from Bedrock") + + # -- format helpers ----------------------------------------------------- # + + @staticmethod + def _to_anthropic_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate our canonical messages format into Anthropic Messages API shape. + + Our format mirrors OpenAI's: role=user/assistant/tool, content can be str + or list. We map `tool` role to a user message with a tool_result block, + and `assistant` messages with tool_calls into tool_use content blocks. + """ + out: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + out.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg.get("tool_call_id", ""), + "content": msg.get("content", ""), + } + ], + } + ) + elif role == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"type": "text", "text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append( + {"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})} + ) + out.append({"role": "assistant", "content": blocks}) + else: + out.append({"role": role or "user", "content": msg.get("content", "")}) + return out + + @staticmethod + def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + out.append( + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": msg.get("tool_call_id", ""), + "content": [{"text": str(msg.get("content", ""))}], + } + } + ], + } + ) + elif role == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append({"toolUse": {"toolUseId": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})}}) + out.append({"role": "assistant", "content": blocks}) + else: + out.append({"role": role or "user", "content": [{"text": str(msg.get("content", ""))}]}) + return out + + +# --------------------------------------------------------------------------- # +# Factory +# --------------------------------------------------------------------------- # + + +def get_llm( + provider: str, + model: str, + image_provider: str, + image_model: str, + region: str = "us-east-1", + api_keys: dict[str, str | None] | None = None, +) -> LLMProvider: + """Build an LLM client for the requested provider(s). + + `api_keys` carries per-provider keys that need explicit wiring (xAI today; + OpenAI reads OPENAI_API_KEY from env directly, Bedrock uses the AWS SDK + credential chain). + """ + api_keys = api_keys or {} + + def build(p: str) -> LLMProvider: + if p == "bedrock": + return BedrockProvider(model=model, image_model=image_model, region=region) + if p == "xai": + return XAIProvider(model=model, image_model=image_model, api_key=api_keys.get("xai")) + return OpenAIProvider(model=model, image_model=image_model) + + text = build(provider) + if image_provider == provider: + return text + return _CompositeProvider(text=text, image=build(image_provider)) + + +@dataclass +class _CompositeProvider: + """Delegates generate_image to a different provider than chat/vision.""" + + text: LLMProvider + image: LLMProvider + + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + return self.text.chat(system, messages, tools=tools, max_tokens=max_tokens, on_delta=on_delta) + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + return self.text.stream_chat(system, messages, on_delta, max_tokens=max_tokens) + + def describe_image(self, image_bytes, mime_type): + return self.text.describe_image(image_bytes, mime_type) + + def generate_image(self, prompt): + return self.image.generate_image(prompt) diff --git a/src/logging_utils.py b/src/logging_utils.py new file mode 100644 index 0000000..0833b90 --- /dev/null +++ b/src/logging_utils.py @@ -0,0 +1,62 @@ +import json +import logging +import os +import sys +from contextvars import ContextVar +from typing import Any + +_request_id: ContextVar[str] = ContextVar("request_id", default="-") + + +def set_request_id(value: str) -> None: + _request_id.set(value) + + +def get_request_id() -> str: + return _request_id.get() + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "request_id": get_request_id(), + "message": record.getMessage(), + } + extra = getattr(record, "extra_fields", None) + if isinstance(extra, dict): + payload.update(extra) + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, default=str) + + +_configured = False + + +def _configure_root() -> None: + global _configured + if _configured: + return + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + handler = logging.StreamHandler(stream=sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(level) + _configured = True + + +def get_logger(name: str) -> logging.LoggerAdapter: + _configure_root() + return logging.LoggerAdapter(logging.getLogger(name), extra={}) + + +def log_event(logger: logging.Logger | logging.LoggerAdapter, event: str, **fields: Any) -> None: + # LoggerAdapter.process() in Python 3.12 overwrites extra= with self.extra, + # so go straight to the underlying logger when present. + base = logger.logger if isinstance(logger, logging.LoggerAdapter) else logger + base.info(event, extra={"extra_fields": fields}) diff --git a/src/slack_helpers.py b/src/slack_helpers.py new file mode 100644 index 0000000..df92307 --- /dev/null +++ b/src/slack_helpers.py @@ -0,0 +1,368 @@ +"""Slack-facing helpers: message splitting, status indicator, user name cache, allowlist.""" +from __future__ import annotations + +import logging +import re +import time +from dataclasses import dataclass +from typing import Any, Iterable + +from slack_sdk.errors import SlackApiError + +logger = logging.getLogger(__name__) + + +CODE_FENCE = "```" +PARAGRAPH_SEP = "\n\n" +SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") + + +class MessageFormatter: + """Split a long message into Slack-safe chunks. + + Strategy (hierarchical): + 1. Prefer splitting on code fences so multi-line code blocks stay intact. + 2. Otherwise split on paragraph boundaries (\\n\\n). + 3. Otherwise split on sentence boundaries. + 4. Final fallback: hard slice at max_len. + """ + + @staticmethod + def split_message(text: str, max_len: int = 2000) -> list[str]: + if not text: + return [""] + if len(text) <= max_len: + return [text] + + if CODE_FENCE in text: + parts = text.split(CODE_FENCE) + chunks: list[str] = [] + for idx, part in enumerate(parts): + wrapped = f"{CODE_FENCE}{part}{CODE_FENCE}" if idx % 2 == 1 else part + if not wrapped: + continue + chunks.extend(MessageFormatter._split_text(wrapped, max_len)) + return MessageFormatter._merge_small(chunks, max_len) + + return MessageFormatter._split_text(text, max_len) + + @staticmethod + def _split_text(text: str, max_len: int) -> list[str]: + if len(text) <= max_len: + return [text] + chunks: list[str] = [] + for paragraph in text.split(PARAGRAPH_SEP): + if len(paragraph) <= max_len: + chunks.append(paragraph) + continue + # paragraph too long: split by sentence + buf = "" + for sentence in SENTENCE_SPLIT_RE.split(paragraph): + if not sentence: + continue + candidate = f"{buf} {sentence}".strip() if buf else sentence + if len(candidate) > max_len: + if buf: + chunks.append(buf) + # sentence itself too long -> hard slice + while len(sentence) > max_len: + chunks.append(sentence[:max_len]) + sentence = sentence[max_len:] + buf = sentence + else: + buf = candidate + if buf: + chunks.append(buf) + return MessageFormatter._merge_small(chunks, max_len) + + @staticmethod + def _merge_small(chunks: Iterable[str], max_len: int) -> list[str]: + out: list[str] = [] + buf = "" + for chunk in chunks: + if not chunk: + continue + candidate = f"{buf}{PARAGRAPH_SEP}{chunk}" if buf else chunk + if len(candidate) <= max_len: + buf = candidate + else: + if buf: + out.append(buf) + buf = chunk + if buf: + out.append(buf) + return out or [""] + + +def set_thread_status(client: Any, channel: str, thread_ts: str, status: str) -> None: + """Set (or clear) the assistant thread's transient status indicator. + + Renders as a typing-like "... is thinking" line in AI-enabled workspaces. + Pass an empty string to clear it. Swallows API errors when the feature + is not enabled on the workspace (tier: assistant.threads). + """ + try: + client.assistant_threads_setStatus(channel_id=channel, thread_ts=thread_ts, status=status) + except (SlackApiError, AttributeError, TypeError) as exc: + logger.debug("assistant_threads_setStatus failed: %s", exc) + + +# --------------------------------------------------------------------------- # +# Streaming message +# --------------------------------------------------------------------------- # + + +class StreamingMessage: + """Stream LLM output into a single Slack message. + + Preferred path uses Slack's native streaming API (chat.startStream / + appendStream / stopStream, available in AI-enabled workspaces). If those + calls fail (unsupported, missing scope, etc.) we fall back to a regular + chat.postMessage + repeated chat.update pattern. + + Appends are throttled by `min_interval` to stay within Slack rate limits + (chat.appendStream is Tier 4 = 100+/min; chat.update is Tier 3 = 50+/min). + """ + + NATIVE_METHOD = "chat.startStream" + APPEND_METHOD = "chat.appendStream" + STOP_METHOD = "chat.stopStream" + + def __init__( + self, + client: Any, + channel: str, + thread_ts: str, + placeholder: str = ":robot_face:", + min_interval: float = 0.6, + max_len: int = 2000, + enable_native: bool = False, + ) -> None: + self.client = client + self.channel = channel + self.thread_ts = thread_ts + self.placeholder = placeholder + self.min_interval = min_interval + # Soft cap for a single Slack message in fallback streaming mode; + # when the rolling buffer approaches this size we finalize the + # current ts and roll to a fresh chat_postMessage. + self.max_len = max_len + # Native Slack streaming (chat.startStream/appendStream/stopStream) + # renders an extra "searching..." status UI beside our message on + # AI-enabled workspaces, which looks like two replies to the user. + # Default off — stream into a plain chat.postMessage + chat.update + # loop so there's exactly one reply ts throughout the session. + self.enable_native = enable_native + self.ts: str | None = None + self._buffer = "" + self._last_flush = 0.0 + self._native = False # True once chat.startStream succeeds + self._stopped = False + + # -- start ---------------------------------------------------------- # + + def start(self) -> None: + """Initialize the streaming message. + + Starts with a plain chat.postMessage so the rest of the lifecycle + is a single ts under our control. If `enable_native` is set we + also try the Slack native streaming API, but it's off by default + because on AI-enabled workspaces it renders an extra "searching" + status UI alongside our reply that looks like a second message. + """ + if self.enable_native: + try: + res = self.client.api_call( + self.NATIVE_METHOD, + params={ + "channel": self.channel, + "thread_ts": self.thread_ts, + "markdown_text": self.placeholder, + }, + ) + if res.get("ok"): + self.ts = res.get("ts") + self._native = True + return + logger.debug("%s returned not-ok: %s", self.NATIVE_METHOD, res.get("error")) + except (SlackApiError, AttributeError, TypeError, KeyError) as exc: + logger.debug("%s failed, falling back to postMessage: %s", self.NATIVE_METHOD, exc) + + # Default path: regular message we'll keep editing with chat.update. + res = self.client.chat_postMessage(channel=self.channel, thread_ts=self.thread_ts, text=self.placeholder) + self.ts = res.get("ts") if isinstance(res, dict) else res["ts"] + + # -- append --------------------------------------------------------- # + + def append(self, delta: str) -> None: + """Accumulate `delta` and flush to Slack if the throttle interval passed.""" + if not delta or self._stopped or not self.ts: + return + self._buffer += delta + now = time.monotonic() + if now - self._last_flush < self.min_interval: + return + self._flush() + self._last_flush = now + + def _flush(self) -> None: + if not self._buffer or not self.ts: + return + text = self._buffer + if self._native: + try: + self.client.api_call( + self.APPEND_METHOD, + params={"channel": self.channel, "ts": self.ts, "markdown_text": text}, + ) + self._buffer = "" + return + except (SlackApiError, AttributeError, TypeError) as exc: + logger.debug("%s failed, downgrading to chat.update: %s", self.APPEND_METHOD, exc) + self._native = False + + # Fallback: chat.update with the full accumulated text plus cursor. + # When the buffer approaches the per-message limit we finalize this + # message and roll into a fresh chat_postMessage so nothing gets lost + # behind a msg_too_long error on the next update. + display = text + " " + self.placeholder + if len(display) >= self.max_len: + try: + self.client.chat_update(channel=self.channel, ts=self.ts, text=text) + except SlackApiError as exc: + logger.warning("chat_update (roll-finalize) failed: %s", exc) + self._roll_to_new_message() + return + try: + self.client.chat_update(channel=self.channel, ts=self.ts, text=display) + except SlackApiError as exc: + logger.warning("chat_update during stream failed: %s", exc) + + def _roll_to_new_message(self) -> None: + """Open a fresh placeholder message and reset the buffer. Used when + the fallback rolling update would overflow the per-message limit.""" + try: + res = self.client.chat_postMessage( + channel=self.channel, + thread_ts=self.thread_ts, + text=self.placeholder, + ) + self.ts = res.get("ts") if isinstance(res, dict) else res["ts"] + self._buffer = "" + except SlackApiError as exc: + logger.warning("roll-to-new-message failed: %s", exc) + + # -- stop ----------------------------------------------------------- # + + def stop(self, final_text: str) -> None: + """Finalize the message with `final_text`. Safe to call once. + + If `final_text` exceeds the per-message limit we split it with the + MessageFormatter, put the first chunk into the current ts, and post + the remaining chunks as additional thread messages. This avoids the + msg_too_long failures we saw when a long answer was written back + through a single chat.update. + """ + if self._stopped or not self.ts: + return + self._stopped = True + + if self._native: + # Native streaming: stopStream accepts up to 12k chars, but be + # conservative and split to self.max_len anyway to keep UX + # consistent with the fallback path. + chunks = MessageFormatter.split_message(final_text, max_len=self.max_len) + try: + self.client.api_call( + self.STOP_METHOD, + params={"channel": self.channel, "ts": self.ts, "markdown_text": chunks[0]}, + ) + for extra in chunks[1:]: + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=extra, + ) + except SlackApiError as exc: + logger.warning("follow-up postMessage failed: %s", exc) + return + except (SlackApiError, AttributeError, TypeError) as exc: + logger.debug("%s failed, finalizing with chat.update: %s", self.STOP_METHOD, exc) + self._native = False + + # Fallback finalizer: split and roll. + chunks = MessageFormatter.split_message(final_text, max_len=self.max_len) + first = chunks[0] + try: + self.client.chat_update(channel=self.channel, ts=self.ts, text=first) + except SlackApiError as exc: + logger.warning("final chat_update failed (len=%d): %s", len(first), exc) + # Fallback to postMessage so at least the text lands somewhere. + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=first, + ) + except SlackApiError as exc2: + logger.warning("final postMessage also failed: %s", exc2) + for extra in chunks[1:]: + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=extra, + ) + except SlackApiError as exc: + logger.warning("follow-up postMessage failed: %s", exc) + + +@dataclass +class UserNameCache: + """Module-level cache keyed by user_id. Survives warm starts.""" + + _cache: dict[str, str] + + @classmethod + def _default(cls) -> "UserNameCache": + return cls(_cache={}) + + def get(self, client: Any, user_id: str) -> str: + if not user_id: + return "" + if user_id in self._cache: + return self._cache[user_id] + try: + info = client.users_info(user=user_id) + profile = (info.get("user") or {}).get("profile") or {} + name = ( + profile.get("display_name") + or profile.get("real_name") + or (info.get("user") or {}).get("real_name") + or user_id + ) + except SlackApiError as exc: + logger.debug("users_info failed for %s: %s", user_id, exc) + name = user_id + self._cache[user_id] = name + return name + + +user_name_cache = UserNameCache._default() + + +def channel_allowed(channel: str, allowed_ids: list[str]) -> bool: + """Return True if no allowlist configured or channel is listed.""" + if not allowed_ids: + return True + return channel in allowed_ids + + +def sanitize_error(exc: BaseException) -> str: + """User-facing error text. Strips internal paths/tokens.""" + msg = str(exc) or exc.__class__.__name__ + # Redact anything that looks like a Slack/OpenAI token. + msg = re.sub(r"xox[abprs]-[A-Za-z0-9-]+", "[redacted-slack-token]", msg) + msg = re.sub(r"sk-[A-Za-z0-9\-_]{10,}", "[redacted-openai-key]", msg) + # Truncate stack-like paths. + msg = re.sub(r"(/[\w./-]+\.py)", "[path]", msg) + if len(msg) > 300: + msg = msg[:297] + "..." + return msg + + diff --git a/src/tools.py b/src/tools.py new file mode 100644 index 0000000..74bf915 --- /dev/null +++ b/src/tools.py @@ -0,0 +1,620 @@ +"""Tool registry + 6 built-in tools with JSON Schema specs. + +Tools are declared once via the `@tool(...)` decorator. The same registry +produces JSON Schemas for LLM function calling AND the executor's dispatch +table. A per-call timeout guards against slow network I/O from one tool +blocking the whole agent loop. +""" +from __future__ import annotations + +import json +import logging +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout +from dataclasses import dataclass, field +from typing import Any, Callable + +from botocore.exceptions import BotoCoreError, ClientError +from slack_sdk.errors import SlackApiError + +from src.config import Settings +from src.llm import LLMProvider, ToolCall +from src.slack_helpers import user_name_cache + +logger = logging.getLogger(__name__) + + +SLACK_FILE_HOSTS = {"files.slack.com", "files-edge.slack.com", "files-pri.slack.com"} +DUCKDUCKGO_HOST = "api.duckduckgo.com" +TAVILY_HOST = "api.tavily.com" +DOC_TEXT_PREFIX = "text/" +DOC_PDF_MIME = "application/pdf" + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # + + +@dataclass +class ToolDef: + name: str + description: str + parameters: dict[str, Any] # JSON Schema + fn: Callable[..., Any] + timeout: float | None = None # None -> use executor default + + +@dataclass +class ToolRegistry: + _tools: dict[str, ToolDef] = field(default_factory=dict) + + def register(self, td: ToolDef) -> None: + self._tools[td.name] = td + + def names(self) -> list[str]: + return list(self._tools.keys()) + + def get(self, name: str) -> ToolDef | None: + return self._tools.get(name) + + def specs(self) -> list[dict[str, Any]]: + return [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in self._tools.values() + ] + + +def tool( + registry: ToolRegistry, + name: str, + description: str, + parameters: dict[str, Any], + timeout: float | None = None, +): + def decorator(fn: Callable[..., Any]): + registry.register( + ToolDef(name=name, description=description, parameters=parameters, fn=fn, timeout=timeout) + ) + return fn + + return decorator + + +# --------------------------------------------------------------------------- # +# Context +# --------------------------------------------------------------------------- # + + +@dataclass +class ToolContext: + slack_client: Any + channel: str + thread_ts: str + event: dict[str, Any] + settings: Settings + llm: LLMProvider + + +# --------------------------------------------------------------------------- # +# Executor +# --------------------------------------------------------------------------- # + + +class ToolExecutor: + def __init__(self, context: ToolContext, registry: ToolRegistry, timeout: float = 20.0): + self.context = context + self.registry = registry + self.timeout = timeout + self._pool = ThreadPoolExecutor(max_workers=2) + + def execute(self, call: ToolCall) -> dict[str, Any]: + td = self.registry.get(call.name) + started = time.monotonic() + if td is None: + return {"ok": False, "error": f"unknown tool: {call.name}"} + effective_timeout = td.timeout if td.timeout is not None else self.timeout + try: + future = self._pool.submit(td.fn, self.context, **(call.arguments or {})) + result = future.result(timeout=effective_timeout) + return {"ok": True, "result": result, "duration_ms": int((time.monotonic() - started) * 1000)} + except FuturesTimeout: + logger.warning("tool %s timed out after %.1fs", call.name, effective_timeout) + return {"ok": False, "error": f"tool '{call.name}' timed out after {effective_timeout}s"} + except ( + TypeError, + ValueError, + KeyError, + urllib.error.URLError, + json.JSONDecodeError, + SlackApiError, + BotoCoreError, + ClientError, + ) as exc: + logger.exception("tool %s failed", call.name) + return {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"} + + +# --------------------------------------------------------------------------- # +# Built-in tools +# --------------------------------------------------------------------------- # + +default_registry = ToolRegistry() + + +@tool( + default_registry, + name="read_attached_images", + description=( + "Read image files and return textual descriptions. By default reads " + "images attached to the current Slack mention. Pass `urls` to also " + "read images referenced from thread history (e.g. url_private_download " + "returned by fetch_thread_history)." + ), + parameters={ + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3}, + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional Slack file URLs to describe (must be on files*.slack.com).", + }, + }, + "required": [], + }, +) +def read_attached_images( + ctx: ToolContext, + limit: int = 3, + urls: list[str] | None = None, +) -> list[dict[str, str]]: + token = ctx.settings.slack_bot_token + out: list[dict[str, str]] = [] + seen: set[str] = set() + + def _fetch(url: str, mime_hint: str, name: str) -> None: + if url in seen: + return + seen.add(url) + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in SLACK_FILE_HOSTS: + raise ValueError("invalid Slack file download URL") + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 (host allowlisted) + data = response.read() + mime = mime_hint if mime_hint.startswith("image/") else _guess_image_mime(url) + if not mime.startswith("image/"): + return + out.append({"name": name, "summary": ctx.llm.describe_image(data, mime)}) + + # 1) Images from the current mention event + for file_info in (ctx.event.get("files") or [])[:limit]: + if len(out) >= limit: + break + mime = str(file_info.get("mimetype", "")) + if not mime.startswith("image/"): + continue + dl = file_info.get("url_private_download") or file_info.get("url_private") + if not dl: + continue + _fetch(dl, mime, file_info.get("name", "image")) + + # 2) Extra URLs provided by the caller (typically from fetch_thread_history) + for extra in (urls or []): + if len(out) >= limit: + break + _fetch(extra, "", _filename_from_url(extra)) + + return out + + +def _guess_image_mime(url: str) -> str: + path = urllib.parse.urlparse(url).path.lower() + for ext, mime in ( + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".gif", "image/gif"), + (".webp", "image/webp"), + (".bmp", "image/bmp"), + (".heic", "image/heic"), + ): + if path.endswith(ext): + return mime + return "image/png" # conservative default; describe_image will still attempt + + +def _filename_from_url(url: str) -> str: + path = urllib.parse.urlparse(url).path + name = path.rsplit("/", 1)[-1] if path else "image" + return name or "image" + + +def _fetch_slack_file(url: str, token: str, max_bytes: int) -> tuple[bytes, str]: + """Fetch a Slack file with size guard. Returns (body, mimetype_from_header). + + Raises: + ValueError: on disallowed host, oversize via Content-Length, or + oversize discovered while reading the body. + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in SLACK_FILE_HOSTS: + raise ValueError("invalid Slack file download URL") + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 + content_length = response.headers.get("Content-Length") if response.headers else None + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError(f"document exceeds MAX_DOC_BYTES={max_bytes}") + body = response.read(max_bytes + 1) + if len(body) > max_bytes: + raise ValueError(f"document exceeds MAX_DOC_BYTES={max_bytes}") + mime = (response.headers.get("Content-Type", "") or "").split(";", 1)[0].strip().lower() if response.headers else "" + return body, mime + + +def _parse_pdf( + data: bytes, + max_pages: int, + max_chars: int, +) -> tuple[str, int, bool]: + """Extract text from a PDF. Raises ValueError for recoverable issues so the + caller can emit a per-document error entry.""" + from io import BytesIO + + # Deferred import keeps pypdf out of cold-start for requests that never + # touch this tool. + from pypdf import PdfReader + from pypdf.errors import PdfReadError, DependencyError + + try: + reader = PdfReader(BytesIO(data)) + except PdfReadError as exc: + raise ValueError(f"PdfReadError: {exc}") from exc + if reader.is_encrypted: + raise ValueError("encrypted PDF not supported") + page_count = len(reader.pages) + if page_count > max_pages: + raise ValueError(f"document exceeds MAX_DOC_PAGES={max_pages}") + pieces: list[str] = [] + total = 0 + truncated = False + for page in reader.pages: + try: + piece = page.extract_text() or "" + except (PdfReadError, DependencyError) as exc: + raise ValueError(f"PdfReadError: {exc}") from exc + pieces.append(piece) + total += len(piece) + if total >= max_chars: + truncated = True + break + text = "\n".join(pieces) + if len(text) > max_chars: + text = text[:max_chars] + truncated = True + return text, page_count, truncated + + +def _parse_text(data: bytes, max_chars: int) -> tuple[str, bool]: + text = data.decode("utf-8", errors="replace") + truncated = len(text) > max_chars + if truncated: + text = text[:max_chars] + return text, truncated + + +@tool( + default_registry, + name="read_attached_document", + description=( + "Read PDF or text/* files attached to the current Slack mention " + "(and optionally extra URLs on files*.slack.com) and return the " + "extracted text. Images are skipped — use read_attached_images " + "for those. Returns one entry per document; if a document fails " + "(encrypted, oversize, corrupt) the entry carries an 'error' key." + ), + parameters={ + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 5, "default": 2}, + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Extra Slack file URLs (must be on files*.slack.com).", + }, + }, + "required": [], + }, + timeout=30.0, +) +def read_attached_document( + ctx: ToolContext, + limit: int = 2, + urls: list[str] | None = None, +) -> list[dict[str, Any]]: + token = ctx.settings.slack_bot_token + max_bytes = ctx.settings.max_doc_bytes + max_chars = ctx.settings.max_doc_chars + out: list[dict[str, Any]] = [] + seen: set[str] = set() + + def _is_doc_mime(mime: str) -> bool: + mime = (mime or "").lower() + return mime == DOC_PDF_MIME or mime.startswith(DOC_TEXT_PREFIX) + + def _process(url: str, file_mime_hint: str, name: str) -> None: + if url in seen or len(out) >= limit: + return + seen.add(url) + try: + body, header_mime = _fetch_slack_file(url, token, max_bytes) + except ValueError as exc: + out.append({"name": name, "error": str(exc)}) + return + except urllib.error.HTTPError as exc: + out.append({"name": name, "error": f"HTTPError: {exc.code}"}) + return + mime = (header_mime or file_mime_hint or "").lower() + if mime == DOC_PDF_MIME: + try: + text, pages, truncated = _parse_pdf( + body, ctx.settings.max_doc_pages, max_chars + ) + except ValueError as exc: + out.append({"name": name, "error": str(exc)}) + return + out.append( + { + "name": name, + "mimetype": DOC_PDF_MIME, + "pages": pages, + "chars": len(text), + "truncated": truncated, + "text": text, + } + ) + return + if mime.startswith(DOC_TEXT_PREFIX): + text, truncated = _parse_text(body, max_chars) + out.append( + { + "name": name, + "mimetype": mime, + "pages": 0, + "chars": len(text), + "truncated": truncated, + "text": text, + } + ) + return + # non-doc mime: silently skip (images handled by read_attached_images) + + for file_info in (ctx.event.get("files") or [])[:limit]: + if len(out) >= limit: + break + mime = str(file_info.get("mimetype", "")) + if not _is_doc_mime(mime): + continue + dl = file_info.get("url_private_download") or file_info.get("url_private") + if not dl: + continue + _process(dl, mime, file_info.get("name", "document")) + + for extra in (urls or []): + if len(out) >= limit: + break + _process(extra, "", _filename_from_url(extra)) + + return out + + +@tool( + default_registry, + name="fetch_thread_history", + description=( + "Fetch recent messages from the current Slack thread for context. " + "Returns each message's user display name, text, file metadata " + "(for images include url_private_download so read_attached_images " + "can describe them), reactions with emoji names and reacting users, " + "and timestamp." + ), + parameters={ + "type": "object", + "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}}, + "required": [], + }, +) +def fetch_thread_history(ctx: ToolContext, limit: int = 20) -> list[dict[str, Any]]: + def _map(res: dict[str, Any]) -> list[dict[str, Any]]: + client = ctx.slack_client + out: list[dict[str, Any]] = [] + for item in res.get("messages", []): + user_id = item.get("user") or item.get("bot_id") or "" + files = [] + for f in item.get("files") or []: + files.append( + { + "name": f.get("name", ""), + "mimetype": f.get("mimetype", ""), + "url_private_download": f.get("url_private_download", ""), + "permalink": f.get("permalink", ""), + "title": f.get("title", ""), + } + ) + reactions = [] + for r in item.get("reactions") or []: + reacting_users = [user_name_cache.get(client, u) for u in (r.get("users") or [])] + reactions.append( + { + "emoji": r.get("name", ""), + "count": r.get("count", 0), + "users": reacting_users, + } + ) + out.append( + { + "user": user_name_cache.get(client, user_id) if user_id else "", + "text": item.get("text", ""), + "ts": item.get("ts", ""), + "files": files, + "reactions": reactions, + } + ) + return out + + return _with_slack_retry( + lambda: ctx.slack_client.conversations_replies( + channel=ctx.channel, ts=ctx.thread_ts, limit=limit + ), + _map, + label="conversations_replies", + ) + + +@tool( + default_registry, + name="search_web", + description="Search the public web for up-to-date information. Uses Tavily if TAVILY_API_KEY is set, otherwise DuckDuckGo Instant Answer.", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5}, + }, + "required": ["query"], + }, +) +def search_web(ctx: ToolContext, query: str, limit: int = 5) -> list[dict[str, str]]: + if ctx.settings.tavily_api_key: + return _tavily_search(ctx.settings.tavily_api_key, query, limit) + return _ddg_search(query, limit) + + +@tool( + default_registry, + name="generate_image", + description="Generate an image from a prompt and upload it to the Slack thread. Returns the permalink.", + parameters={ + "type": "object", + "properties": {"prompt": {"type": "string"}}, + "required": ["prompt"], + }, + timeout=75.0, # gpt-image-1 / titan / stability can take 30–60s +) +def generate_image(ctx: ToolContext, prompt: str) -> dict[str, str]: + image_bytes = ctx.llm.generate_image(prompt) + upload = ctx.slack_client.files_upload_v2( + channel=ctx.channel, + thread_ts=ctx.thread_ts, + title="Generated image", + filename="generated.png", + file=image_bytes, + ) + file_info = upload.get("file", {}) + return {"permalink": file_info.get("permalink", ""), "title": file_info.get("title", "generated.png")} + + +@tool( + default_registry, + name="get_current_time", + description=( + "Return the current wall-clock time. Uses the server default " + "timezone (DEFAULT_TIMEZONE env) unless 'timezone' is provided. " + "Useful for 'today', 'now', 'this week', or weekday questions." + ), + parameters={ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": ( + "Optional IANA timezone (e.g. 'Asia/Seoul', 'UTC', " + "'America/New_York'). Omit to use the server default." + ), + } + }, + "required": [], + }, +) +def get_current_time(ctx: ToolContext, timezone: str | None = None) -> dict[str, Any]: + from datetime import datetime + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + tz_name = timezone or ctx.settings.default_timezone + try: + tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError as exc: + raise ValueError(f"unknown timezone: {tz_name}") from exc + now = datetime.now(tz) + return { + "iso": now.isoformat(timespec="seconds"), + "timezone": tz_name, + "weekday": now.strftime("%A"), + "unix": int(now.timestamp()), + } + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _with_slack_retry(call: Callable[[], Any], map_result: Callable[[Any], Any], label: str, attempts: int = 3) -> Any: + delay = 1.0 + last: SlackApiError | None = None + for attempt in range(attempts): + try: + return map_result(call()) + except SlackApiError as exc: + error = (exc.response or {}).get("error") if hasattr(exc, "response") else None + if error == "ratelimited" and attempt < attempts - 1: + retry_after = int((exc.response.headers or {}).get("Retry-After", delay)) if hasattr(exc, "response") else delay + logger.warning("%s rate limited, sleeping %ds", label, retry_after) + time.sleep(retry_after) + delay *= 2 + last = exc + continue + raise + if last: + raise last + return [] + + +def _ddg_search(query: str, limit: int) -> list[dict[str, str]]: + params = urllib.parse.urlencode({"q": query, "format": "json", "no_redirect": 1, "no_html": 1}) + url = f"https://{DUCKDUCKGO_HOST}/?{params}" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != DUCKDUCKGO_HOST: + raise ValueError("invalid web search URL") + with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + results: list[dict[str, str]] = [] + if payload.get("AbstractURL"): + results.append({"title": payload.get("AbstractText", ""), "url": payload["AbstractURL"]}) + for item in payload.get("RelatedTopics", []): + if "Text" in item and "FirstURL" in item: + results.append({"title": item["Text"], "url": item["FirstURL"]}) + if len(results) >= limit: + break + return results[:limit] + + +def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]]: + url = f"https://{TAVILY_HOST}/search" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != TAVILY_HOST: + raise ValueError("invalid Tavily URL") + body = json.dumps({"api_key": api_key, "query": query, "max_results": limit}).encode() + req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + return [ + {"title": r.get("title", ""), "url": r.get("url", ""), "content": r.get("content", "")} + for r in payload.get("results", [])[:limit] + ] + + From 10e0784f10501e0f592459f2741788b3677b1625 Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:33:17 +0900 Subject: [PATCH 03/65] refactor: Update AWS role policy and streamline deployment workflow --- .github/aws-role/role-policy.json | 10 +-- .github/workflows/push.yml | 137 +++++++++++------------------- 2 files changed, 53 insertions(+), 94 deletions(-) diff --git a/.github/aws-role/role-policy.json b/.github/aws-role/role-policy.json index 8db686f..926bd0d 100644 --- a/.github/aws-role/role-policy.json +++ b/.github/aws-role/role-policy.json @@ -90,9 +90,7 @@ ], "Resource": [ "arn:aws:s3:::lambda-gurumi-bot-*", - "arn:aws:s3:::lambda-gurumi-bot-*/*", - "arn:aws:s3:::gurumi-ai-bot-*", - "arn:aws:s3:::gurumi-ai-bot-*/*" + "arn:aws:s3:::lambda-gurumi-bot-*/*" ] }, { @@ -110,7 +108,7 @@ "dynamodb:ListTagsOfResource" ], "Resource": [ - "arn:aws:dynamodb:*:*:table/gurumi-ai-bot-*" + "arn:aws:dynamodb:*:*:table/lambda-gurumi-bot-*" ] }, { @@ -160,8 +158,8 @@ "s3vectors:ListTagsForResource" ], "Resource": [ - "arn:aws:s3vectors:*:*:bucket/gurumi-ai-bot-*", - "arn:aws:s3vectors:*:*:bucket/gurumi-ai-bot-*/index/*" + "arn:aws:s3vectors:*:*:bucket/lambda-gurumi-bot-*", + "arn:aws:s3vectors:*:*:bucket/lambda-gurumi-bot-*/index/*" ] }, { diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index e363aa5..d5d8695 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -2,107 +2,68 @@ name: Deploy to AWS Lambda on: push: - branches: - - main - repository_dispatch: - types: - - deploy - workflow_dispatch: + branches: [main] + workflow_dispatch: {} + repository_dispatch: {} -env: - AWS_REGION: "us-east-1" - AWS_ROLE_NAME: "lambda-gurumi-bot" - - STAGE: "dev" - - FOUNDATION_MODEL: ${{ vars.FOUNDATION_MODEL }} - - ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} - ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} - BOT_CURSOR: ${{ vars.BOT_CURSOR }} - MAX_LEN_BEDROCK: ${{ vars.MAX_LEN_BEDROCK }} - MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} - MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} - PERSONAL_MESSAGE: ${{ vars.PERSONAL_MESSAGE }} - REACTION_EMOJIS: ${{ vars.REACTION_EMOJIS }} - SLACK_SAY_INTERVAL: ${{ vars.SLACK_SAY_INTERVAL }} - SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} - - AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} - -# Permission can be added at job level or workflow level permissions: - id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout + id-token: write + contents: read jobs: - deploy: + test-and-deploy: runs-on: ubuntu-24.04 - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/checkout@v4 - - name: Setup Python 3.12 🐍 - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - - name: Install dependencies - run: npm install - - - name: Install Python dependencies - run: npx serverless plugin install --name serverless-python-requirements + - uses: actions/setup-node@v4 + with: + node-version: "20" - - name: Install dotenv plugin - run: npx serverless plugin install --name serverless-dotenv-plugin + - name: Install Python deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt - - name: Install dependencies - run: pip install -r requirements.txt + - name: Run tests + run: pytest --cov=src --cov-report=term-missing - - name: Set up environment variables 📝 + - name: Install Serverless + plugins run: | - echo "FOUNDATION_MODEL=${FOUNDATION_MODEL}" >> .env - echo "ALLOWED_CHANNEL_IDS=${ALLOWED_CHANNEL_IDS}" >> .env - echo "ALLOWED_CHANNEL_MESSAGE=${ALLOWED_CHANNEL_MESSAGE}" >> .env - echo "BOT_CURSOR=${BOT_CURSOR}" >> .env - echo "MAX_LEN_BEDROCK=${MAX_LEN_BEDROCK}" >> .env - echo "MAX_LEN_SLACK=${MAX_LEN_SLACK}" >> .env - echo "MAX_THROTTLE_COUNT=${MAX_THROTTLE_COUNT}" >> .env - echo "PERSONAL_MESSAGE=${PERSONAL_MESSAGE}" >> .env - echo "REACTION_EMOJIS=${REACTION_EMOJIS}" >> .env - echo "SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}" >> .env - echo "SLACK_SAY_INTERVAL=${SLACK_SAY_INTERVAL}" >> .env - echo "SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET}" >> .env - echo "SYSTEM_MESSAGE=${SYSTEM_MESSAGE}" >> .env + npm install -g serverless@3 + npm install serverless-python-requirements - - name: configure aws credentials + - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" - role-session-name: github-actions-ci-bot - aws-region: ${{ env.AWS_REGION }} - - - name: Deploy to AWS Lambda 🚀 - run: npx serverless deploy --stage ${{ env.STAGE }} --region ${{ env.AWS_REGION }} - - - name: Update Bedrock Agent Alias 🔄 - run: | - AGENT_ID=$(aws cloudformation describe-stacks \ - --stack-name lambda-gurumi-bot-${{ env.STAGE }} \ - --query "Stacks[0].Outputs[?OutputKey=='AgentId'].OutputValue" \ - --output text) - ALIAS_ID=$(aws cloudformation describe-stacks \ - --stack-name lambda-gurumi-bot-${{ env.STAGE }} \ - --query "Stacks[0].Outputs[?OutputKey=='AgentAliasId'].OutputValue" \ - --output text) - - aws bedrock-agent update-agent-alias \ - --agent-id "${AGENT_ID}" \ - --agent-alias-id "${ALIAS_ID}" \ - --agent-alias-name live \ - --region ${{ env.AWS_REGION }} + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/lambda-slack-bot + aws-region: us-east-1 + + - name: Deploy + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + LLM_PROVIDER: ${{ vars.LLM_PROVIDER }} + LLM_MODEL: ${{ vars.LLM_MODEL }} + IMAGE_PROVIDER: ${{ vars.IMAGE_PROVIDER }} + IMAGE_MODEL: ${{ vars.IMAGE_MODEL }} + RESPONSE_LANGUAGE: ${{ vars.RESPONSE_LANGUAGE }} + AGENT_MAX_STEPS: ${{ vars.AGENT_MAX_STEPS }} + MAX_OUTPUT_TOKENS: ${{ vars.MAX_OUTPUT_TOKENS }} + MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} + MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} + MAX_HISTORY_CHARS: ${{ vars.MAX_HISTORY_CHARS }} + BOT_CURSOR: ${{ vars.BOT_CURSOR }} + ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} + ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} + SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} + LOG_LEVEL: ${{ vars.LOG_LEVEL }} + run: npx serverless deploy --stage dev --region us-east-1 From af6bca30d3596a7703c7d555e3dfd5a208db6069 Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:37:12 +0900 Subject: [PATCH 04/65] fix: Update role-to-assume in AWS credentials configuration for deployment --- .github/workflows/push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d5d8695..cba4bf9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -41,7 +41,7 @@ jobs: - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/lambda-slack-bot + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/lambda-gurumi-bot aws-region: us-east-1 - name: Deploy From a9aea20be746b3b5af9dff2272887b31fd545fba Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:39:49 +0900 Subject: [PATCH 05/65] Add unit tests for logging utilities, Slack helpers, and tool functionalities - Implement tests for JsonFormatter to ensure request_id and extra fields are included in logs. - Create tests for MessageFormatter to validate message splitting and handling of code blocks. - Add tests for UserNameCache to verify caching behavior and error handling. - Introduce tests for channel_allowed and sanitize_error functions to ensure proper functionality. - Develop comprehensive tests for ToolExecutor, including timeout handling and error capturing. - Add tests for reading attached images and documents, ensuring proper handling of various file types and limits. - Implement tests for web search and image generation tools to validate expected outputs. --- tests/__init__.py | 0 tests/test_agent.py | 259 +++++++++++++ tests/test_config.py | 146 +++++++ tests/test_dedup.py | 118 ++++++ tests/test_llm.py | 648 +++++++++++++++++++++++++++++++ tests/test_logging_utils.py | 56 +++ tests/test_slack_helpers.py | 247 ++++++++++++ tests/test_tools.py | 740 ++++++++++++++++++++++++++++++++++++ 8 files changed, 2214 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_config.py create mode 100644 tests/test_dedup.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_logging_utils.py create mode 100644 tests/test_slack_helpers.py create mode 100644 tests/test_tools.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..b962deb --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,259 @@ +from unittest.mock import MagicMock + +from src.agent import SlackMentionAgent +from src.llm import LLMResult, ToolCall +from src.tools import ToolContext, ToolRegistry, tool + + +def _ctx(): + return ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=MagicMock(), + llm=MagicMock(), + ) + + +class ScriptedLLM: + def __init__(self, results: list[LLMResult]): + self._results = list(results) + self.calls: list[dict] = [] + + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + self.calls.append({"messages": list(messages), "tools": tools, "on_delta": on_delta}) + if not self._results: + result = LLMResult(content="(empty)", tool_calls=[], stop_reason="end_turn") + else: + result = self._results.pop(0) + if on_delta is not None and result.content and not result.tool_calls: + on_delta(result.content) + return result + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + result = self.chat(system, messages) + if on_delta and result.content: + on_delta(result.content) + return result.content + + def describe_image(self, b, m): + return "desc" + + def generate_image(self, p): + return b"img" + + +def _registry_with_search(): + reg = ToolRegistry() + + @tool(reg, name="search_web", description="", parameters={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}) + def _search(ctx, query): + return [{"title": "AWS", "url": "https://aws.amazon.com"}] + + return reg + + +def test_agent_terminates_when_no_tool_calls(): + reg = _registry_with_search() + llm = ScriptedLLM([LLMResult(content="final", tool_calls=[], stop_reason="end_turn")]) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("question") + assert result.text == "final" + assert result.steps == 1 + assert result.tool_calls_count == 0 + + +def test_agent_runs_tool_then_returns_text(): + reg = _registry_with_search() + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "aws"})], + stop_reason="tool_use", + ), + LLMResult(content="결과 기반 답변", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("질문") + assert result.text == "결과 기반 답변" + assert result.tool_calls_count == 1 + assert result.steps == 2 + + +def test_agent_duplicate_call_is_skipped(): + reg = _registry_with_search() + called = {"count": 0} + + reg = ToolRegistry() + + @tool(reg, name="search_web", description="", parameters={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}) + def _search(ctx, query): + called["count"] += 1 + return [{"title": "X"}] + + # LLM calls the same tool twice with identical args, then finishes. + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "same"})], + stop_reason="tool_use", + ), + LLMResult( + content="", + tool_calls=[ToolCall(id="c2", name="search_web", arguments={"query": "same"})], + stop_reason="tool_use", + ), + LLMResult(content="done", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=5) + agent.run("q") + assert called["count"] == 1 # second call suppressed + + +def test_agent_captures_image_url(): + reg = ToolRegistry() + + @tool(reg, name="generate_image", description="", parameters={"type": "object", "properties": {"prompt": {"type": "string"}}, "required": ["prompt"]}) + def _gen(ctx, prompt): + return {"permalink": "https://slack/x"} + + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="generate_image", arguments={"prompt": "cat"})], + stop_reason="tool_use", + ), + LLMResult(content="here is your image", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("그려줘") + assert result.image_url == "https://slack/x" + + +def test_agent_forces_final_compose_at_max_steps(): + reg = _registry_with_search() + # Every step returns more tool calls — never ends. + def infinite(): + while True: + yield LLMResult( + content="", + tool_calls=[ToolCall(id="x", name="search_web", arguments={"query": "q"})], + stop_reason="tool_use", + ) + + class EndlessLLM: + def __init__(self): + self._gen = infinite() + self.end_called = False + + def chat(self, *a, **k): + return next(self._gen) + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + self.end_called = True + if on_delta: + on_delta("forced") + return "forced" + + def describe_image(self, *a, **k): + return "" + + def generate_image(self, *a, **k): + return b"" + + llm = EndlessLLM() + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=2, on_stream=lambda d: None) + result = agent.run("q") + assert result.text == "forced" + assert llm.end_called is True + assert result.steps == 2 + + +def test_agent_on_step_fires_for_tool_use_and_compose(): + reg = _registry_with_search() + events: list[tuple[int, str, dict]] = [] + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "x"})], + stop_reason="tool_use", + ), + LLMResult(content="done", tool_calls=[], stop_reason="end_turn"), + ] + ) + agent = SlackMentionAgent( + llm=llm, + context=_ctx(), + registry=reg, + max_steps=3, + on_step=lambda step, phase, detail: events.append((step, phase, detail)), + ) + agent.run("q") + phases = [p for _, p, _ in events] + assert "tool_use" in phases + assert "tool_result" in phases + assert "compose" in phases + # compose should fire on the second hop (step=2) without max_steps_hit flag + compose_events = [e for e in events if e[1] == "compose"] + assert compose_events[0][2].get("max_steps_hit") is not True + + +def test_agent_streams_final_answer_when_on_stream_set(): + """When on_stream is set, chat(on_delta=...) streams content during the + terminal hop. The agent must not then re-call the LLM to re-stream.""" + reg = _registry_with_search() + delta_buffer: list[str] = [] + + class StreamingLLM(ScriptedLLM): + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + self.calls.append({"on_delta": on_delta}) + # emit three deltas through on_delta, no tool_calls + if on_delta is not None: + for chunk in ["재미있", "는 답변", "입니다"]: + on_delta(chunk) + return LLMResult(content="재미있는 답변입니다", tool_calls=[], stop_reason="end_turn") + + llm = StreamingLLM([]) + agent = SlackMentionAgent( + llm=llm, + context=_ctx(), + registry=reg, + max_steps=3, + on_stream=delta_buffer.append, + ) + result = agent.run("q") + assert delta_buffer == ["재미있", "는 답변", "입니다"] + assert result.text == "재미있는 답변입니다" + # Exactly one chat call — no compose re-call. + assert len(llm.calls) == 1 + + +def test_agent_aggregates_token_usage(): + reg = _registry_with_search() + llm = ScriptedLLM( + [ + LLMResult( + content="", + tool_calls=[ToolCall(id="c1", name="search_web", arguments={"query": "x"})], + stop_reason="tool_use", + token_usage={"input": 10, "output": 20}, + ), + LLMResult( + content="done", + tool_calls=[], + stop_reason="end_turn", + token_usage={"input": 5, "output": 7}, + ), + ] + ) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + result = agent.run("q") + assert result.token_usage == {"input": 15, "output": 27} diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..250adc3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,146 @@ +import pytest + + +@pytest.fixture +def reload_config(): + """Build a fresh Settings from the current os.environ (no module reload).""" + + def _reload(): + from src.config import Settings + + return Settings.from_env() + + return _reload + + +def _clear_env(monkeypatch): + for key in [ + "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "LLM_PROVIDER", "LLM_MODEL", + "IMAGE_PROVIDER", "IMAGE_MODEL", "OPENAI_API_KEY", "RESPONSE_LANGUAGE", + "AGENT_MAX_STEPS", "DYNAMODB_TABLE_NAME", "AWS_REGION", "ALLOWED_CHANNEL_IDS", + "ALLOWED_CHANNEL_MESSAGE", "MAX_LEN_SLACK", "MAX_THROTTLE_COUNT", + "MAX_HISTORY_CHARS", "BOT_CURSOR", "SYSTEM_MESSAGE", "TAVILY_API_KEY", "XAI_API_KEY", "LOG_LEVEL", + "DEFAULT_TIMEZONE", "MAX_DOC_CHARS", "MAX_DOC_PAGES", "MAX_DOC_BYTES", + ]: + monkeypatch.delenv(key, raising=False) + + +def test_defaults(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.llm_provider == "openai" + assert s.llm_model == "gpt-4o-mini" + assert s.image_provider == "openai" + assert s.image_model == "gpt-image-1" + assert s.response_language == "ko" + assert s.agent_max_steps == 3 + assert s.max_len_slack == 2000 + assert s.allowed_channel_ids == [] + assert s.tavily_api_key is None + + +def test_invalid_enum_falls_back(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("RESPONSE_LANGUAGE", "jp") + monkeypatch.setenv("LLM_PROVIDER", "mystery") + s = reload_config() + assert s.response_language == "ko" + assert s.llm_provider == "openai" + + +def test_invalid_int_falls_back_to_default(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("AGENT_MAX_STEPS", "not-an-int") + s = reload_config() + assert s.agent_max_steps == 3 + + +def test_int_below_minimum_clamped(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_LEN_SLACK", "10") + s = reload_config() + assert s.max_len_slack == 500 + + +def test_list_env_splits_commas(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("ALLOWED_CHANNEL_IDS", "C1,C2, C3 ") + s = reload_config() + assert s.allowed_channel_ids == ["C1", "C2", "C3"] + + +def test_list_env_none_sentinel(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("ALLOWED_CHANNEL_IDS", "None") + s = reload_config() + assert s.allowed_channel_ids == [] + + +def test_require_slack_credentials_raises_when_missing(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + with pytest.raises(RuntimeError): + s.require_slack_credentials() + + +def test_require_slack_credentials_ok(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-x") + monkeypatch.setenv("SLACK_SIGNING_SECRET", "secret") + s = reload_config() + s.require_slack_credentials() # no raise + + +def test_xai_provider_is_a_valid_enum_value(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("LLM_PROVIDER", "xai") + monkeypatch.setenv("IMAGE_PROVIDER", "xai") + s = reload_config() + assert s.llm_provider == "xai" + assert s.image_provider == "xai" + + +def test_xai_api_key_default_none_and_override(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.xai_api_key is None + + monkeypatch.setenv("XAI_API_KEY", "xai-abc") + s2 = reload_config() + assert s2.xai_api_key == "xai-abc" + + +def test_doc_limits_defaults(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.default_timezone == "Asia/Seoul" + assert s.max_doc_chars == 20_000 + assert s.max_doc_pages == 50 + assert s.max_doc_bytes == 25 * 1024 * 1024 + + +def test_default_timezone_fallback_on_invalid_env(monkeypatch, reload_config, caplog): + _clear_env(monkeypatch) + monkeypatch.setenv("DEFAULT_TIMEZONE", "Narnia/Center") + with caplog.at_level("WARNING"): + s = reload_config() + assert s.default_timezone == "Asia/Seoul" + assert any("DEFAULT_TIMEZONE" in rec.message for rec in caplog.records) + + +def test_doc_limits_honor_env_and_clamp(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_DOC_CHARS", "5000") + monkeypatch.setenv("MAX_DOC_PAGES", "0") # below minimum → clamps to 1 + monkeypatch.setenv("MAX_DOC_BYTES", "100") # below minimum → clamps to 65536 + s = reload_config() + assert s.max_doc_chars == 5000 + assert s.max_doc_pages == 1 + assert s.max_doc_bytes == 65_536 + + +def test_default_timezone_custom_value(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("DEFAULT_TIMEZONE", "America/New_York") + s = reload_config() + assert s.default_timezone == "America/New_York" diff --git a/tests/test_dedup.py b/tests/test_dedup.py new file mode 100644 index 0000000..f74e420 --- /dev/null +++ b/tests/test_dedup.py @@ -0,0 +1,118 @@ +import time + +import boto3 +import pytest + +try: + from moto import mock_aws +except ImportError: # pragma: no cover + pytest.skip("moto not installed", allow_module_level=True) + +from src.dedup import ConversationStore, DedupStore + + +TABLE = "lambda-slack-bot-test" +REGION = "us-east-1" + + +def _create_table(): + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=TABLE, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "user", "AttributeType": "S"}, + {"AttributeName": "expire_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": "user-index", + "KeySchema": [ + {"AttributeName": "user", "KeyType": "HASH"}, + {"AttributeName": "expire_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "KEYS_ONLY"}, + "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + } + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + +@mock_aws +def test_dedup_reserve_first_call_succeeds(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.reserve("abc") is True + + +@mock_aws +def test_dedup_reserve_second_call_returns_false(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.reserve("abc") is True + assert store.reserve("abc") is False + + +@mock_aws +def test_dedup_different_keys_independent(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.reserve("a") is True + assert store.reserve("b") is True + + +@mock_aws +def test_count_user_active_ignores_expired(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + store.reserve("fresh", user="U1", ttl_seconds=3600) + # Manually insert an expired record for the same user. + boto3.resource("dynamodb", region_name=REGION).Table(TABLE).put_item( + Item={"id": "dedup:old", "user": "U1", "expire_at": int(time.time()) - 10} + ) + assert store.count_user_active("U1") == 1 + + +@mock_aws +def test_count_user_active_unknown_user_zero(): + _create_table() + store = DedupStore(table_name=TABLE, region=REGION) + assert store.count_user_active("nobody") == 0 + + +@mock_aws +def test_conversation_put_and_get_roundtrip(): + _create_table() + convo = ConversationStore(table_name=TABLE, region=REGION) + msgs = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}] + convo.put("T1", "U1", msgs) + assert convo.get("T1") == msgs + + +@mock_aws +def test_conversation_get_missing_returns_empty(): + _create_table() + convo = ConversationStore(table_name=TABLE, region=REGION) + assert convo.get("unseen") == [] + + +@mock_aws +def test_conversation_truncate_to_chars(): + _create_table() + convo = ConversationStore(table_name=TABLE, region=REGION) + msgs = [{"role": "user", "content": "x" * 1000} for _ in range(10)] + convo.put("T1", "U1", msgs, max_chars=3000) + stored = convo.get("T1") + import json + assert len(json.dumps(stored, ensure_ascii=False)) <= 3000 + assert len(stored) < len(msgs) + + +def test_conversation_truncate_helper_direct(): + msgs = [{"role": "user", "content": "x" * 500} for _ in range(5)] + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=1200) + import json + assert len(json.dumps(trimmed, ensure_ascii=False)) <= 1200 + assert len(trimmed) < len(msgs) diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..a5e9191 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,648 @@ +import json +from unittest.mock import MagicMock, patch + +from src.llm import BedrockProvider, OpenAIProvider, ToolCall, XAIProvider, get_llm + + +# --------------------------------------------------------------------------- # +# OpenAI +# --------------------------------------------------------------------------- # + + +def _openai_completion(content="", tool_calls=None, finish="stop"): + choice = MagicMock() + choice.finish_reason = finish + choice.message.content = content + choice.message.tool_calls = tool_calls or [] + completion = MagicMock() + completion.choices = [choice] + completion.usage.prompt_tokens = 10 + completion.usage.completion_tokens = 20 + return completion + + +def _openai_tool_call(call_id, name, args_obj): + tc = MagicMock() + tc.id = call_id + tc.function.name = name + tc.function.arguments = json.dumps(args_obj) + return tc + + +def test_openai_chat_parses_text(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="hello") + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "hello" + assert result.stop_reason == "end_turn" + assert result.tool_calls == [] + assert result.token_usage == {"input": 10, "output": 20} + + +def test_openai_legacy_model_uses_max_tokens_and_temperature(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_tokens" in kwargs + assert "temperature" in kwargs + assert "max_completion_tokens" not in kwargs + + +def test_openai_new_generation_uses_max_completion_tokens(): + provider = OpenAIProvider(model="gpt-5.4", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_completion_tokens" in kwargs + assert "max_tokens" not in kwargs + assert "temperature" not in kwargs + + +def test_openai_o1_model_uses_max_completion_tokens(): + provider = OpenAIProvider(model="o1-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_completion_tokens" in kwargs + assert "temperature" not in kwargs + + +def _make_stream_chunk(*, content=None, tool_calls=None, finish=None, usage=None): + chunk = MagicMock() + if usage is not None: + chunk.usage = usage + else: + chunk.usage = None + if content is None and tool_calls is None and finish is None: + chunk.choices = [] + return chunk + choice = MagicMock() + choice.finish_reason = finish + choice.delta.content = content + choice.delta.tool_calls = tool_calls + chunk.choices = [choice] + return chunk + + +def _stream_tool_call(index, call_id=None, name=None, arguments=None): + tc = MagicMock() + tc.index = index + tc.id = call_id + fn = MagicMock() + fn.name = name + fn.arguments = arguments + tc.function = fn + return tc + + +def test_openai_chat_streams_content_when_on_delta_given(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + chunks = [ + _make_stream_chunk(content="hello"), + _make_stream_chunk(content=" world"), + _make_stream_chunk(finish="stop"), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + received: list[str] = [] + result = provider.chat(system="s", messages=[], on_delta=received.append) + + assert received == ["hello", " world"] + assert result.content == "hello world" + assert result.stop_reason == "end_turn" + assert result.tool_calls == [] + # must have requested streaming + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert kwargs.get("stream") is True + + +def test_openai_chat_stream_suppresses_content_after_tool_calls_begin(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + chunks = [ + # tool_call begins first (no content yet) + _make_stream_chunk(tool_calls=[_stream_tool_call(0, call_id="c1", name="search_web")]), + _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='{"query":')]), + _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='"x"}')]), + # Model sometimes also emits commentary content after initiating a tool_call. + _make_stream_chunk(content="I'll search."), + _make_stream_chunk(finish="tool_calls"), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + received: list[str] = [] + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + on_delta=received.append, + ) + + # Content after tool_calls started must NOT reach on_delta. + assert received == [] + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "x"} + assert result.stop_reason == "tool_use" + + +def test_openai_chat_stream_accumulates_usage_from_last_chunk(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + usage = MagicMock() + usage.prompt_tokens = 42 + usage.completion_tokens = 7 + chunks = [ + _make_stream_chunk(content="hi"), + _make_stream_chunk(finish="stop", usage=usage), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + result = provider.chat(system="s", messages=[], on_delta=lambda _: None) + assert result.token_usage == {"input": 42, "output": 7} + + +def test_openai_translates_canonical_tool_calls(): + """Canonical assistant tool_calls must be serialized to OpenAI's wire format.""" + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="done") + + canonical = [ + {"role": "user", "content": "ask"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "c1", "name": "search_web", "arguments": {"query": "q"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "{\"ok\": true}"}, + ] + provider.chat(system="s", messages=canonical) + sent = provider._client.chat.completions.create.call_args.kwargs["messages"] + # system + 3 canonical = 4 + assert len(sent) == 4 + assistant = sent[2] + assert assistant["role"] == "assistant" + assert assistant["tool_calls"][0]["type"] == "function" + assert assistant["tool_calls"][0]["function"]["name"] == "search_web" + # arguments must be a JSON string, not a dict + assert isinstance(assistant["tool_calls"][0]["function"]["arguments"], str) + import json as _json + + assert _json.loads(assistant["tool_calls"][0]["function"]["arguments"]) == {"query": "q"} + + +def test_openai_chat_parses_tool_calls(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + tc = _openai_tool_call("call_1", "search_web", {"query": "aws"}) + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) + assert result.stop_reason == "tool_use" + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "aws"} + + +def test_openai_chat_handles_bad_tool_arguments(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + tc = MagicMock() + tc.id = "x" + tc.function.name = "search_web" + tc.function.arguments = "not json" + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) + assert result.tool_calls[0].arguments == {} + + +def test_openai_stream_chat_invokes_callback(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + + def _chunk(text): + ch = MagicMock() + ch.choices[0].delta.content = text + return ch + + provider._client.chat.completions.create.return_value = iter([_chunk("he"), _chunk("llo")]) + seen = [] + result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) + assert result == "hello" + assert seen == ["he", "llo"] + + +# --------------------------------------------------------------------------- # +# Bedrock — Claude +# --------------------------------------------------------------------------- # + + +def _bedrock_response(payload: dict): + body = MagicMock() + body.read.return_value = json.dumps(payload).encode() + return {"body": body} + + +def test_bedrock_inference_profile_routes_to_claude(): + """us.anthropic.claude-* inference profile IDs must still hit the Claude + path (tool_use, messages API), not the unknown-family fallback.""" + provider = BedrockProvider( + model="us.anthropic.claude-opus-4-6-v1", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 2}, + } + ) + tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] + result = provider.chat(system="s", messages=[], tools=tools) + assert result.content == "hi" + # Claude body carries tools (not Nova's toolConfig) + body = provider._client.invoke_model.call_args.kwargs["body"] + import json as _json + + parsed = _json.loads(body) + assert "tools" in parsed # routed into _claude_chat, not fallback + assert parsed["tools"][0]["name"] == "search_web" + + +def test_bedrock_inference_profile_image_routing(): + """global./us. prefixed image model IDs still reach the Titan/Nova body.""" + provider = BedrockProvider( + model="us.anthropic.claude-opus-4-6-v1", + image_model="us.amazon.nova-canvas-v1:0", + region="us-east-1", + ) + body = provider._build_image_body("cat") + assert body["taskType"] == "TEXT_IMAGE" + + +def test_bedrock_claude_chat_text(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [{"type": "text", "text": "안녕"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 7}, + } + ) + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "안녕" + assert result.stop_reason == "end_turn" + assert result.token_usage == {"input": 5, "output": 7} + + +def test_bedrock_claude_chat_with_tool_use(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [ + {"type": "text", "text": "I'll search."}, + {"type": "tool_use", "id": "tu_1", "name": "search_web", "input": {"query": "x"}}, + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 3, "output_tokens": 4}, + } + ) + tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] + result = provider.chat(system="s", messages=[], tools=tools) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "x"} + + +def test_bedrock_message_translation_tool_role(): + messages = [ + {"role": "user", "content": "ask"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "t1", "name": "foo", "arguments": {"a": 1}}]}, + {"role": "tool", "tool_call_id": "t1", "content": "{\"ok\":true}"}, + ] + translated = BedrockProvider._to_anthropic_messages(messages) + assert translated[0] == {"role": "user", "content": "ask"} + assert translated[1]["role"] == "assistant" + assert translated[1]["content"][0]["type"] == "tool_use" + assert translated[1]["content"][0]["name"] == "foo" + assert translated[2]["role"] == "user" + assert translated[2]["content"][0]["type"] == "tool_result" + + +# --------------------------------------------------------------------------- # +# Bedrock — Nova +# --------------------------------------------------------------------------- # + + +def test_bedrock_nova_chat_text(): + provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 2}, + } + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "hi" + assert result.stop_reason == "end_turn" + assert result.token_usage == {"input": 1, "output": 2} + + +def test_bedrock_nova_tool_use(): + provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": { + "message": { + "content": [ + {"text": "let me search"}, + {"toolUse": {"toolUseId": "tu1", "name": "search_web", "input": {"query": "q"}}}, + ] + } + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 2, "outputTokens": 3}, + } + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + ) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + + +# --------------------------------------------------------------------------- # +# Image model adapter +# --------------------------------------------------------------------------- # + + +def test_build_image_body_titan(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + body = provider._build_image_body("a cat") + assert body["taskType"] == "TEXT_IMAGE" + + +def test_build_image_body_stability(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="stability.stable-diffusion-xl-v1", + region="us-east-1", + ) + body = provider._build_image_body("a cat") + assert body["text_prompts"][0]["text"] == "a cat" + + +def test_build_image_body_unknown_raises(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="mystery.v1", + region="us-east-1", + ) + import pytest + + with pytest.raises(ValueError): + provider._build_image_body("x") + + +def test_bedrock_describe_image_returns_text(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"content": [{"type": "text", "text": "a cat"}]} + ) + out = provider.describe_image(b"fake", "image/png") + assert out == "a cat" + + +def test_bedrock_generate_image_titan_returns_bytes(): + import base64 as _b64 + + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"images": [_b64.b64encode(b"imgdata").decode()]} + ) + assert provider.generate_image("cat") == b"imgdata" + + +def test_bedrock_generate_image_stability_returns_bytes(): + import base64 as _b64 + + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="stability.stable-diffusion-xl-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"artifacts": [{"base64": _b64.b64encode(b"xyz").decode()}]} + ) + assert provider.generate_image("cat") == b"xyz" + + +def test_openai_describe_image_uses_vision_format(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="it's a cat") + out = provider.describe_image(b"\x89PNG", "image/png") + assert out == "it's a cat" + args = provider._client.chat.completions.create.call_args.kwargs + assert args["messages"][0]["content"][1]["type"] == "image_url" + + +def test_openai_generate_image_decodes_b64(): + import base64 as _b64 + + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=_b64.b64encode(b"hello").decode())] + provider._client.images.generate.return_value = response + assert provider.generate_image("cat") == b"hello" + kwargs = provider._client.images.generate.call_args.kwargs + # gpt-image-1 must NOT send response_format (API rejects it) + assert "response_format" not in kwargs + + +def test_openai_generate_image_dalle_sends_response_format(): + import base64 as _b64 + + provider = OpenAIProvider(model="gpt-4o-mini", image_model="dall-e-3") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=_b64.b64encode(b"ok").decode())] + provider._client.images.generate.return_value = response + provider.generate_image("cat") + kwargs = provider._client.images.generate.call_args.kwargs + assert kwargs["response_format"] == "b64_json" + + +def test_composite_provider_routes_image_to_image_llm(): + from src.llm import _CompositeProvider + + text = MagicMock() + image = MagicMock() + image.generate_image.return_value = b"img" + composite = _CompositeProvider(text=text, image=image) + composite.chat(system="s", messages=[]) + text.chat.assert_called_once() + composite.generate_image("x") + image.generate_image.assert_called_once_with("x") + + +# --------------------------------------------------------------------------- # +# xAI (Grok) +# --------------------------------------------------------------------------- # + + +def test_xai_provider_uses_xai_base_url_and_api_key(): + """XAIProvider must instantiate OpenAI client with the xAI base URL and + the explicit api_key, so traffic goes to api.x.ai rather than OpenAI.""" + provider = XAIProvider( + model="grok-4-1-fast-reasoning", + image_model="grok-imagine-image", + api_key="xai-test", + ) + with patch("openai.OpenAI") as openai_ctor: + openai_ctor.return_value = MagicMock() + provider._get_client() + kwargs = openai_ctor.call_args.kwargs + assert kwargs.get("base_url") == "https://api.x.ai/v1" + assert kwargs.get("api_key") == "xai-test" + + +def test_xai_chat_parses_tool_calls(): + """Grok returns the same wire shape as OpenAI for tool calls; the + shared parser must turn them into ToolCall objects.""" + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + tc = _openai_tool_call("call_g1", "search_web", {"query": "xai"}) + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + ) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "xai"} + + +def test_xai_chat_uses_legacy_max_tokens_always(): + """All current grok chat models accept max_tokens + temperature; + XAIProvider must not switch to max_completion_tokens (OpenAI-only split).""" + provider = XAIProvider(model="grok-4.20-0309-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="hi") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_tokens" in kwargs + assert "temperature" in kwargs + assert "max_completion_tokens" not in kwargs + + +def test_xai_generate_image_skips_size_and_requests_b64(): + """xAI images.generate rejects `size` (uses aspect_ratio/resolution). + We must omit it and explicitly ask for b64_json so we can decode bytes + into files_upload_v2.""" + import base64 as _b64 + + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=_b64.b64encode(b"xai-bytes").decode())] + provider._client.images.generate.return_value = response + + assert provider.generate_image("a cat") == b"xai-bytes" + kwargs = provider._client.images.generate.call_args.kwargs + assert kwargs["model"] == "grok-imagine-image" + assert kwargs["prompt"] == "a cat" + assert kwargs["response_format"] == "b64_json" + assert "size" not in kwargs # xAI rejects this + + +def test_xai_stream_chat_emits_deltas(): + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + + def _chunk(text): + ch = MagicMock() + ch.choices[0].delta.content = text + return ch + + provider._client.chat.completions.create.return_value = iter([_chunk("gr"), _chunk("ok")]) + seen: list[str] = [] + result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) + assert result == "grok" + assert seen == ["gr", "ok"] + + +# --------------------------------------------------------------------------- # +# Factory +# --------------------------------------------------------------------------- # + + +def test_get_llm_builds_xai_provider(): + provider = get_llm( + provider="xai", + model="grok-4-1-fast-reasoning", + image_provider="xai", + image_model="grok-imagine-image", + region="us-east-1", + api_keys={"xai": "xai-secret"}, + ) + assert isinstance(provider, XAIProvider) + assert provider._api_key == "xai-secret" + + +def test_get_llm_composite_xai_text_openai_image(): + """Mixed-provider setups still work through _CompositeProvider.""" + from src.llm import _CompositeProvider + + provider = get_llm( + provider="xai", + model="grok-4-1-fast-reasoning", + image_provider="openai", + image_model="gpt-image-1", + region="us-east-1", + api_keys={"xai": "xai-key"}, + ) + assert isinstance(provider, _CompositeProvider) + assert isinstance(provider.text, XAIProvider) + assert isinstance(provider.image, OpenAIProvider) diff --git a/tests/test_logging_utils.py b/tests/test_logging_utils.py new file mode 100644 index 0000000..2aaf47d --- /dev/null +++ b/tests/test_logging_utils.py @@ -0,0 +1,56 @@ +import json +import logging +from io import StringIO + +from src.logging_utils import JsonFormatter, get_logger, log_event, set_request_id + + +def test_json_formatter_includes_request_id(): + set_request_id("req-abc") + rec = logging.LogRecord("x", logging.INFO, "f.py", 1, "hello", None, None) + formatted = JsonFormatter().format(rec) + payload = json.loads(formatted) + assert payload["request_id"] == "req-abc" + assert payload["message"] == "hello" + assert payload["level"] == "INFO" + + +def test_json_formatter_includes_extra_fields(): + rec = logging.LogRecord("x", logging.INFO, "f.py", 1, "evt", None, None) + rec.extra_fields = {"key": "value", "n": 3} + payload = json.loads(JsonFormatter().format(rec)) + assert payload["key"] == "value" + assert payload["n"] == 3 + + +def test_json_formatter_renders_exception(): + try: + raise ValueError("boom") + except ValueError: + import sys + + rec = logging.LogRecord("x", logging.ERROR, "f.py", 1, "err", None, sys.exc_info()) + payload = json.loads(JsonFormatter().format(rec)) + assert "boom" in payload["exc"] + + +def test_log_event_attaches_fields(): + """log_event should emit a LogRecord whose extra_fields match the kwargs.""" + captured: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record): + captured.append(record) + + underlying = logging.getLogger("test.log_event") + underlying.addHandler(_Capture()) + underlying.setLevel(logging.INFO) + try: + adapter = logging.LoggerAdapter(underlying, extra={}) + log_event(adapter, "my.event", tool="x", count=5) + finally: + underlying.handlers = [h for h in underlying.handlers if not isinstance(h, _Capture)] + + assert captured, "no log record emitted" + extras = getattr(captured[0], "extra_fields", None) + assert extras == {"tool": "x", "count": 5} diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py new file mode 100644 index 0000000..7b93d92 --- /dev/null +++ b/tests/test_slack_helpers.py @@ -0,0 +1,247 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk.errors import SlackApiError + +from src.slack_helpers import ( + MessageFormatter, + UserNameCache, + channel_allowed, + sanitize_error, +) + + +def test_split_short_message_is_single_chunk(): + chunks = MessageFormatter.split_message("hello", max_len=100) + assert chunks == ["hello"] + + +def test_split_by_paragraph(): + para1 = "A" * 1500 + para2 = "B" * 1500 + text = f"{para1}\n\n{para2}" + chunks = MessageFormatter.split_message(text, max_len=2000) + assert len(chunks) == 2 + assert all(len(c) <= 2000 for c in chunks) + + +def test_split_by_sentence_when_paragraph_too_long(): + sent = "Sentence with some length. " * 100 # long paragraph without \n\n + chunks = MessageFormatter.split_message(sent, max_len=300) + assert len(chunks) > 1 + assert all(len(c) <= 300 for c in chunks) + + +def test_split_keeps_small_code_blocks_intact(): + """Code blocks that fit within max_len should not be split.""" + body = "text before\n\n```\nprint('x')\n```\n\ntext after" + chunks = MessageFormatter.split_message(body, max_len=2000) + # Entire content fits; must be a single chunk. + assert len(chunks) == 1 + assert chunks[0].count("```") == 2 + + +def test_split_code_block_longer_than_max_len_still_respects_limit(): + """When a code block exceeds max_len, fences may not balance per chunk, + but no chunk may exceed max_len.""" + code = "```\n" + ("def x():\n return 1\n" * 100) + "```" + chunks = MessageFormatter.split_message(code, max_len=500) + assert all(len(c) <= 500 for c in chunks) + # Total fence count preserved across all chunks. + total_fences = sum(c.count("```") for c in chunks) + assert total_fences == 2 + + +def test_split_empty_string(): + assert MessageFormatter.split_message("", max_len=100) == [""] + + +def test_user_name_cache_uses_display_name(): + cache = UserNameCache._default() + client = MagicMock() + client.users_info.return_value = {"user": {"profile": {"display_name": "Alice"}}} + assert cache.get(client, "U1") == "Alice" + # second call is cached + assert cache.get(client, "U1") == "Alice" + client.users_info.assert_called_once() + + +def test_user_name_cache_falls_back_to_user_id_on_error(): + cache = UserNameCache._default() + client = MagicMock() + client.users_info.side_effect = SlackApiError("fail", {}) + assert cache.get(client, "U2") == "U2" + + +def test_channel_allowed_no_allowlist(): + assert channel_allowed("C1", []) is True + + +def test_channel_allowed_allowlist_match(): + assert channel_allowed("C1", ["C1", "C2"]) is True + + +def test_channel_allowed_allowlist_miss(): + assert channel_allowed("C9", ["C1", "C2"]) is False + + +def test_sanitize_error_redacts_tokens(): + class FakeErr(Exception): + pass + + exc = FakeErr("failed with token xoxb-12345-67890 for /path/to/file.py boom") + out = sanitize_error(exc) + assert "xoxb-12345" not in out + assert "redacted-slack-token" in out + assert "[path]" in out + + +def test_sanitize_error_redacts_openai_key(): + exc = ValueError("Bad request using sk-proj-abcdefghij1234567890xyz") + out = sanitize_error(exc) + assert "sk-proj" not in out + + +def test_sanitize_error_truncates_long(): + exc = ValueError("x" * 1000) + out = sanitize_error(exc) + assert len(out) <= 300 + + +# --------------------------------------------------------------------------- # +# StreamingMessage +# --------------------------------------------------------------------------- # + +from src.slack_helpers import StreamingMessage + + +def _slack_client_native_stream(): + """Client whose api_call responds to chat.startStream/appendStream/stopStream.""" + client = MagicMock() + + def api_call(method, params=None, **_): + if method == "chat.startStream": + return {"ok": True, "channel": params["channel"], "ts": "1234.5678"} + if method == "chat.appendStream": + return {"ok": True, "channel": params["channel"], "ts": params["ts"]} + if method == "chat.stopStream": + return {"ok": True, "channel": params["channel"], "ts": params["ts"]} + raise AssertionError(f"unexpected api_call: {method}") + + client.api_call.side_effect = api_call + return client + + +def test_streaming_message_native_start_uses_api_call(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", placeholder=":robot:", enable_native=True) + sm.start() + assert sm.ts == "1234.5678" + assert sm._native is True + client.api_call.assert_called_once() + assert client.api_call.call_args.args[0] == "chat.startStream" + + +def test_streaming_message_fallback_when_native_fails(): + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "fallback-ts"} + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1") + sm.start() + assert sm.ts == "fallback-ts" + assert sm._native is False + client.chat_postMessage.assert_called_once() + + +def test_streaming_message_append_throttles(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", min_interval=10.0, enable_native=True) + sm.start() + # First append should flush (last_flush=0 -> elapsed > interval) + sm.append("hello ") + # Second append within interval: should buffer, not flush + sm.append("world") + # Count of appendStream calls should be <= 1 within this tight window + append_calls = [c for c in client.api_call.call_args_list if c.args[0] == "chat.appendStream"] + assert len(append_calls) <= 1 + + +def test_streaming_message_stop_finalizes_native(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", enable_native=True) + sm.start() + sm.stop("final answer") + stop_calls = [c for c in client.api_call.call_args_list if c.args[0] == "chat.stopStream"] + assert len(stop_calls) == 1 + assert stop_calls[0].kwargs["params"]["markdown_text"] == "final answer" + + +def test_streaming_message_stop_fallback_uses_chat_update(): + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "fallback-ts"} + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1") + sm.start() + sm.stop("done") + client.chat_update.assert_called_with(channel="C1", ts="fallback-ts", text="done") + + +def test_streaming_message_stop_is_idempotent(): + client = _slack_client_native_stream() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", enable_native=True) + sm.start() + sm.stop("a") + sm.stop("b") + stop_calls = [c for c in client.api_call.call_args_list if c.args[0] == "chat.stopStream"] + assert len(stop_calls) == 1 # only first stop fires + + +def test_streaming_message_append_noop_before_start(): + client = MagicMock() + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1") + sm.append("hi") # should not explode, ts is None + client.api_call.assert_not_called() + + +def test_streaming_message_fallback_rolls_when_buffer_exceeds_limit(): + """In fallback mode, when the rolling buffer approaches max_len we must + finalize the current ts and open a fresh chat_postMessage so nothing gets + lost behind msg_too_long.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + # start -> one placeholder, then every new postMessage returns a new ts + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "first"}, + {"ok": True, "ts": "second"}, + ] + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=50) + sm.start() + # Force one flush so we have state; buffer is small. + sm.append("hi") + # Feed enough content to exceed max_len on the next flush. + sm.append("x" * 60) + # After the roll, ts should have advanced and the buffer should be empty. + assert sm.ts == "second" + assert sm._buffer == "" + # chat_update was used to finalize the first message content before rolling. + assert client.chat_update.called + + +def test_streaming_message_stop_splits_long_final(): + """A final_text longer than max_len must land in multiple messages.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + # Return a fresh ts for every postMessage so the test is not sensitive + # to how many follow-ups the splitter produces. + client.chat_postMessage.return_value = {"ok": True, "ts": "post-ts"} + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", max_len=100) + sm.start() + final = "para one " * 30 + "\n\n" + "para two " * 30 + "\n\n" + "para three " * 30 + sm.stop(final) + # first chunk went to chat_update on the placeholder ts + assert client.chat_update.call_args.kwargs["ts"] == "post-ts" + # at least one follow-up post message was issued (beyond the initial placeholder) + follow_calls = [c for c in client.chat_postMessage.call_args_list if c.kwargs.get("thread_ts") == "ts1"] + assert len(follow_calls) >= 1 + + diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..2e4e381 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,740 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from src.config import Settings +from src.llm import ToolCall +from src.tools import ( + ToolContext, + ToolExecutor, + ToolRegistry, + default_registry, + fetch_thread_history, + generate_image, + read_attached_images, + search_web, +) + + +def _settings(**overrides) -> Settings: + base = { + "slack_bot_token": "xoxb-test", + "slack_signing_secret": "sig", + "llm_provider": "openai", + "llm_model": "gpt-4o-mini", + "image_provider": "openai", + "image_model": "gpt-image-1", + "agent_max_steps": 3, + "response_language": "ko", + "dynamodb_table_name": "t", + "aws_region": "us-east-1", + } + base.update(overrides) + return Settings(**base) + + +def _ctx(event=None, slack_client=None, llm=None): + return ToolContext( + slack_client=slack_client or MagicMock(), + channel="C1", + thread_ts="ts1", + event=event or {}, + settings=_settings(), + llm=llm or MagicMock(), + ) + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # + + +def test_default_registry_has_expected_tools(): + names = set(default_registry.names()) + assert { + "read_attached_images", + "fetch_thread_history", + "search_web", + "generate_image", + "get_current_time", + "read_attached_document", + }.issubset(names) + assert "search_slack_messages" not in names # removed — user-token only, tied to installer + + +def test_registry_specs_match_llm_shape(): + for spec in default_registry.specs(): + assert set(spec.keys()) == {"name", "description", "parameters"} + assert spec["parameters"]["type"] == "object" + + +# --------------------------------------------------------------------------- # +# Executor +# --------------------------------------------------------------------------- # + + +def test_executor_unknown_tool(): + registry = ToolRegistry() + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="nope", arguments={})) + assert result["ok"] is False + assert "unknown tool" in result["error"] + + +def test_executor_timeout_guards_slow_tools(): + import time + + registry = ToolRegistry() + + def slow(ctx): + time.sleep(1.0) + + from src.tools import ToolDef + + registry.register(ToolDef(name="slow", description="", parameters={"type": "object", "properties": {}}, fn=slow)) + executor = ToolExecutor(_ctx(), registry, timeout=0.1) + result = executor.execute(ToolCall(id="1", name="slow", arguments={})) + assert result["ok"] is False + assert "timed out" in result["error"] + + +def test_executor_wraps_boto_client_error(): + """Bedrock invoke failures (botocore ClientError) must be returned as + {ok: False, error: ...} so the LLM can plan around the failure instead + of the exception bubbling out of the agent loop.""" + from botocore.exceptions import ClientError + + registry = ToolRegistry() + + def failing_bedrock(ctx): + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Legacy model"}}, + "InvokeModel", + ) + + from src.tools import ToolDef + + registry.register( + ToolDef( + name="bedrock_thing", + description="", + parameters={"type": "object", "properties": {}}, + fn=failing_bedrock, + ) + ) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="bedrock_thing", arguments={})) + assert result["ok"] is False + assert "ResourceNotFoundException" in result["error"] or "Legacy" in result["error"] + + +def test_executor_captures_tool_error(): + registry = ToolRegistry() + + def boom(ctx): + raise ValueError("nope") + + from src.tools import ToolDef + + registry.register(ToolDef(name="boom", description="", parameters={"type": "object", "properties": {}}, fn=boom)) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="boom", arguments={})) + assert result["ok"] is False + assert "nope" in result["error"] + + +def test_executor_per_tool_timeout_override(): + """A tool registered with its own timeout overrides the executor default.""" + import time + + registry = ToolRegistry() + + def moderately_slow(ctx): + time.sleep(0.3) + return "done" + + from src.tools import ToolDef + + registry.register( + ToolDef( + name="slowish", + description="", + parameters={"type": "object", "properties": {}}, + fn=moderately_slow, + timeout=1.0, + ) + ) + # Default timeout short enough to kill a naïve tool; per-tool override lets + # this one finish. + executor = ToolExecutor(_ctx(), registry, timeout=0.1) + result = executor.execute(ToolCall(id="1", name="slowish", arguments={})) + assert result["ok"] is True + assert result["result"] == "done" + + +def test_generate_image_tool_has_extended_timeout(): + """Image generation is slow; its registered timeout must be > default.""" + td = default_registry.get("generate_image") + assert td is not None + assert td.timeout is not None + assert td.timeout >= 60.0 + + +# --------------------------------------------------------------------------- # +# read_attached_images SSRF guard +# --------------------------------------------------------------------------- # + + +def test_read_attached_images_rejects_non_slack_host(): + event = {"files": [{"mimetype": "image/png", "url_private_download": "https://evil.example.com/x.png"}]} + with pytest.raises(ValueError): + read_attached_images(_ctx(event=event), limit=1) + + +def test_read_attached_images_rejects_http_scheme(): + event = {"files": [{"mimetype": "image/png", "url_private_download": "http://files.slack.com/x.png"}]} + with pytest.raises(ValueError): + read_attached_images(_ctx(event=event), limit=1) + + +def test_read_attached_images_accepts_slack_host_variants(): + event = { + "files": [ + {"mimetype": "image/png", "url_private_download": "https://files-pri.slack.com/x.png", "name": "a"}, + ] + } + llm = MagicMock() + llm.describe_image.return_value = "a cat" + ctx = _ctx(event=event, llm=llm) + with patch("src.tools.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"fake" + result = read_attached_images(ctx, limit=1) + assert result == [{"name": "a", "summary": "a cat"}] + + +def test_read_attached_images_skips_non_image_mimetypes(): + event = {"files": [{"mimetype": "application/pdf", "url_private_download": "https://files.slack.com/x.pdf"}]} + assert read_attached_images(_ctx(event=event), limit=1) == [] + + +# --------------------------------------------------------------------------- # +# fetch_thread_history +# --------------------------------------------------------------------------- # + + +def test_fetch_thread_history_resolves_user_files_and_reactions(): + """History should carry display names, file metadata, and reactions so the + LLM can answer things like "누가 좋아요 눌렀어?" or "아까 그 이미지 분석해줘".""" + from src.slack_helpers import user_name_cache + + # Reset the module-level cache so prior tests don't leak. + user_name_cache._cache.clear() + + client = MagicMock() + client.conversations_replies.return_value = { + "messages": [ + { + "user": "U1", + "text": "look at this", + "ts": "1713.1", + "files": [ + { + "name": "cat.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/x/cat.png", + "permalink": "https://slack/p1", + "title": "cute", + } + ], + }, + { + "user": "U2", + "text": "nice!", + "ts": "1713.2", + "reactions": [ + {"name": "thumbsup", "count": 2, "users": ["U1", "U3"]}, + ], + }, + ] + } + + def _users_info(user): + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client.users_info.side_effect = _users_info + + out = fetch_thread_history(_ctx(slack_client=client), limit=5) + assert len(out) == 2 + first, second = out + assert first["user"] == "name-U1" + assert first["text"] == "look at this" + assert first["ts"] == "1713.1" + assert first["files"] == [ + { + "name": "cat.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/x/cat.png", + "permalink": "https://slack/p1", + "title": "cute", + } + ] + assert first["reactions"] == [] + + assert second["user"] == "name-U2" + assert second["files"] == [] + assert second["reactions"] == [ + {"emoji": "thumbsup", "count": 2, "users": ["name-U1", "name-U3"]} + ] + + +def test_read_attached_images_accepts_extra_urls(): + """Images referenced from fetch_thread_history (url_private_download) must + be loadable via read_attached_images(urls=[...]).""" + ctx = _ctx() + ctx.llm.describe_image.return_value = "a cat history" + with patch("src.tools.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"fake-bytes" + out = read_attached_images( + ctx, + limit=5, + urls=["https://files.slack.com/x/cat.png"], + ) + assert out == [{"name": "cat.png", "summary": "a cat history"}] + + +def test_read_attached_images_urls_reject_non_slack_host(): + ctx = _ctx() + with pytest.raises(ValueError): + read_attached_images(ctx, urls=["https://evil.example.com/cat.png"]) + + +def test_read_attached_images_respects_total_limit_across_event_and_urls(): + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/e1.png", + "name": "e1.png", + } + ] + } + ctx = _ctx(event=event) + ctx.llm.describe_image.return_value = "desc" + with patch("src.tools.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"x" + out = read_attached_images( + ctx, + limit=2, + urls=[ + "https://files.slack.com/u1.png", + "https://files.slack.com/u2.png", # should be skipped (limit=2) + ], + ) + assert len(out) == 2 + assert {item["name"] for item in out} == {"e1.png", "u1.png"} + + +# --------------------------------------------------------------------------- # +# search_web +# --------------------------------------------------------------------------- # + + +def test_search_web_ddg_parses_results(): + ctx = _ctx() + payload = { + "AbstractURL": "https://example.com/a", + "AbstractText": "abstract", + "RelatedTopics": [{"Text": "t1", "FirstURL": "https://example.com/1"}], + } + with patch("src.tools.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() + results = search_web(ctx, query="q", limit=5) + assert results[0]["url"] == "https://example.com/a" + assert results[1]["url"] == "https://example.com/1" + + +def test_search_web_uses_tavily_when_key_set(): + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=_settings(tavily_api_key="tvly-xyz"), + llm=MagicMock(), + ) + payload = {"results": [{"title": "t", "url": "https://x", "content": "c"}]} + with patch("src.tools.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() + out = search_web(ctx, query="q", limit=5) + assert out == [{"title": "t", "url": "https://x", "content": "c"}] + + +# --------------------------------------------------------------------------- # +# generate_image +# --------------------------------------------------------------------------- # + + +def test_generate_image_returns_permalink(): + llm = MagicMock() + llm.generate_image.return_value = b"imgbytes" + client = MagicMock() + client.files_upload_v2.return_value = {"file": {"permalink": "https://slack/abc", "title": "t"}} + ctx = _ctx(slack_client=client, llm=llm) + out = generate_image(ctx, prompt="cat") + assert out == {"permalink": "https://slack/abc", "title": "t"} + llm.generate_image.assert_called_once_with("cat") + + +# --------------------------------------------------------------------------- # +# get_current_time +# --------------------------------------------------------------------------- # + + +def test_get_current_time_uses_default_timezone(): + from src.tools import get_current_time + + ctx = _ctx() # _settings() default_timezone defaults to Asia/Seoul + out = get_current_time(ctx) + assert out["timezone"] == "Asia/Seoul" + assert out["iso"].endswith("+09:00") + # Weekday is a full English day name (Monday..Sunday) + assert out["weekday"] in { + "Monday", "Tuesday", "Wednesday", "Thursday", + "Friday", "Saturday", "Sunday", + } + assert isinstance(out["unix"], int) + + +def test_get_current_time_respects_custom_timezone(): + from src.tools import get_current_time + + ctx = _ctx() + out = get_current_time(ctx, timezone="UTC") + assert out["timezone"] == "UTC" + assert out["iso"].endswith("+00:00") + + +def test_get_current_time_invalid_tz_via_executor(): + """Invalid timezone should surface as {ok: False, error: ...} via the + executor so the LLM can recover.""" + from src.tools import default_registry + + executor = ToolExecutor(_ctx(), default_registry) + result = executor.execute( + ToolCall(id="t1", name="get_current_time", arguments={"timezone": "Narnia/Center"}) + ) + assert result["ok"] is False + assert "unknown timezone" in result["error"] + + +# --------------------------------------------------------------------------- # +# read_attached_document +# --------------------------------------------------------------------------- # + + +def test_read_attached_document_text_file(): + from src.tools import read_attached_document + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/notes.txt", + "name": "notes.txt", + } + ] + } + ctx = _ctx(event=event) + body = b"Hello\n world.\nLine 3." + with patch("src.tools.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.read.return_value = body + resp.headers = {"Content-Length": str(len(body))} + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + entry = out[0] + assert entry["name"] == "notes.txt" + assert entry["mimetype"] == "text/plain" + assert entry["truncated"] is False + assert "Hello" in entry["text"] + assert entry["chars"] == len(entry["text"]) + assert entry["pages"] == 0 # text files report 0 pages + + +def _build_pdf_bytes(pages_text: list[str]) -> bytes: + """Build a minimal PDF (one page per string) using reportlab. Test-only.""" + from io import BytesIO + from reportlab.pdfgen.canvas import Canvas + from reportlab.lib.pagesizes import letter + + buf = BytesIO() + canvas = Canvas(buf, pagesize=letter) + for text in pages_text: + canvas.drawString(72, 720, text) + canvas.showPage() + canvas.save() + return buf.getvalue() + + +def _mock_pdf_response(opener, body: bytes, headers=None): + """Wire the urlopen mock to stream `body` in chunks through `_fetch_slack_file`.""" + resp = opener.return_value.__enter__.return_value + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + resp.read.side_effect = _chunked + resp.headers = dict(headers or {"Content-Length": str(len(body)), "Content-Type": "application/pdf"}) + + +def test_read_attached_document_pdf_happy_path(): + from src.tools import read_attached_document + + pdf = _build_pdf_bytes(["Hello PDF page one.", "Page two here."]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "name": "report.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + entry = out[0] + assert entry["name"] == "report.pdf" + assert entry["pages"] == 2 + assert entry["truncated"] is False + assert entry["chars"] > 0 + + +def test_read_attached_document_pdf_truncation(): + from src.tools import read_attached_document + + pdf = _build_pdf_bytes(["A" * 500]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/big.pdf", + "name": "big.pdf", + } + ] + } + ctx = _ctx( + event=event, + ) + ctx = ToolContext( + slack_client=ctx.slack_client, + channel=ctx.channel, + thread_ts=ctx.thread_ts, + event=ctx.event, + settings=_settings(max_doc_chars=50), + llm=ctx.llm, + ) + with patch("src.tools.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert out[0]["truncated"] is True + assert out[0]["chars"] == 50 + + +def test_read_attached_document_page_cap(): + from src.tools import read_attached_document + + pdf = _build_pdf_bytes(["p1", "p2", "p3"]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/pages.pdf", + "name": "pages.pdf", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_pages=2), + llm=MagicMock(), + ) + with patch("src.tools.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_PAGES" in out[0]["error"] + + +def test_read_attached_document_size_cap_via_content_length(): + from src.tools import read_attached_document + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/huge.txt", + "name": "huge.txt", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_bytes=100), # tiny cap + llm=MagicMock(), + ) + with patch("src.tools.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": "200"} # > cap + resp.read.return_value = b"x" * 10 # should never be read past cap + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_BYTES" in out[0]["error"] + + +def test_read_attached_document_size_cap_via_streamed_read(): + from src.tools import read_attached_document + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/nohead.txt", + "name": "nohead.txt", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_bytes=100), + llm=MagicMock(), + ) + body = b"y" * 200 + with patch("src.tools.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {} # no Content-Length + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + resp.read.side_effect = _chunked + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_BYTES" in out[0]["error"] + + +def test_read_attached_document_rejects_non_slack_host(): + from src.tools import read_attached_document + + ctx = _ctx() + out = read_attached_document( + ctx, urls=["https://evil.example.com/foo.pdf"], limit=1 + ) + assert len(out) == 1 + assert "error" in out[0] + assert "invalid" in out[0]["error"].lower() + + +def test_read_attached_document_skips_encrypted_pdf(): + from src.tools import read_attached_document + from io import BytesIO + from pypdf import PdfWriter + + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + # NOTE: pypdf>=4.0 uses keyword-only user_password. If requirements.txt's + # upper pin is ever relaxed past 6.0, verify this signature still holds. + writer.encrypt(user_password="secret") + buf = BytesIO() + writer.write(buf) + encrypted_pdf = buf.getvalue() + + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/enc.pdf", + "name": "enc.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, encrypted_pdf) + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "encrypted" in out[0]["error"] + + +def test_read_attached_document_skips_image_mime(): + from src.tools import read_attached_document + + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/a.png", + "name": "a.png", + } + ] + } + ctx = _ctx(event=event) + # urlopen should NOT be called — image MIMEs are filtered before fetch + with patch("src.tools.urllib.request.urlopen") as opener: + out = read_attached_document(ctx, limit=1) + opener.assert_not_called() + assert out == [] + + +def test_read_attached_document_http_error_returns_per_item(): + from src.tools import read_attached_document + import urllib.error + + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/missing.pdf", + "name": "missing.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.urllib.request.urlopen") as opener: + opener.side_effect = urllib.error.HTTPError( + url="https://files.slack.com/missing.pdf", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + assert "error" in out[0] + assert "404" in out[0]["error"] From 328a16f40e7059d1f3931bdd6187fc6c047d8299 Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:40:54 +0900 Subject: [PATCH 06/65] docs: Add missing image for Gurumi Bot in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b972310..d31f747 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xAI(Grok) LLM 으로 네이티브 **function calling** 기반 툴 오케스트레이션을 수행하는 봇입니다. +![Gurumi Bot](images/gurumi-bot.png) + ## 봇의 처리 흐름 (절대 생략하지 않는다) 모든 사용자 메시지는 다음 네 단계를 **순서대로** 통과합니다: From cca9f5d7006e835f337812dd930d9f5d226125da Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:44:03 +0900 Subject: [PATCH 07/65] feat: Add permissions for API Gateway tags in role policy --- .github/aws-role/role-policy.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/aws-role/role-policy.json b/.github/aws-role/role-policy.json index 926bd0d..dbef3e3 100644 --- a/.github/aws-role/role-policy.json +++ b/.github/aws-role/role-policy.json @@ -123,7 +123,9 @@ ], "Resource": [ "arn:aws:apigateway:*::/restapis", - "arn:aws:apigateway:*::/restapis/*" + "arn:aws:apigateway:*::/restapis/*", + "arn:aws:apigateway:*::/tags", + "arn:aws:apigateway:*::/tags/*" ] }, { From 24ba902c44a27fbc7865de83c2865b0fee75cc48 Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:48:58 +0900 Subject: [PATCH 08/65] refactor: Rename job from 'test-and-deploy' to 'deploy' in workflow configuration --- .github/workflows/push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index cba4bf9..c98302b 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -11,7 +11,7 @@ permissions: contents: read jobs: - test-and-deploy: + deploy: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 From d1e04c45ea48f63564ea7228a7028f39031fb50c Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 00:53:11 +0900 Subject: [PATCH 09/65] chore: Update action versions and enhance step descriptions in workflow files --- .github/workflows/push.yml | 21 ++++++++++++--------- .github/workflows/sync-awsdocs.yml | 14 +++++++------- .github/workflows/sync-notion.yml | 18 +++++++++--------- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index c98302b..92b5dfe 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -14,37 +14,40 @@ jobs: deploy: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - name: Checkout 🛎️ + uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - name: Set up Python 🐍 + uses: actions/setup-python@v6 with: python-version: "3.12" - - uses: actions/setup-node@v4 + - name: Set up Node.js 🟢 + uses: actions/setup-node@v5 with: node-version: "20" - - name: Install Python deps + - name: Install Python deps 📦 run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Run tests + - name: Run tests and check coverage ✅ run: pytest --cov=src --cov-report=term-missing - - name: Install Serverless + plugins + - name: Install Serverless + plugins 🛠️ run: | npm install -g serverless@3 npm install serverless-python-requirements - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials (OIDC) 🔑 + uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/lambda-gurumi-bot aws-region: us-east-1 - - name: Deploy + - name: Deploy to AWS Lambda 🚀 env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} diff --git a/.github/workflows/sync-awsdocs.yml b/.github/workflows/sync-awsdocs.yml index 6d30bc0..2b7a8e5 100644 --- a/.github/workflows/sync-awsdocs.yml +++ b/.github/workflows/sync-awsdocs.yml @@ -25,19 +25,19 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Install qpdf + - name: Install qpdf 📄 run: sudo apt-get install -y qpdf - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials (OIDC) 🔑 + uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" role-session-name: github-actions-sync-awsdocs aws-region: ${{ env.AWS_REGION }} - - name: Get stack outputs + - name: Get stack outputs 📝 id: stack run: | BUCKET=$(aws cloudformation describe-stack-resource \ @@ -59,10 +59,10 @@ jobs: --output text) echo "ds_id=${DS_ID}" >> "$GITHUB_OUTPUT" - - name: Sync AWS docs to S3 + - name: Sync AWS docs to S3 📂 run: ./scripts/awsdocs/sync.sh ${{ steps.stack.outputs.bucket }} - - name: Start Knowledge Base ingestion + - name: Start Knowledge Base ingestion 🚀 run: | aws bedrock-agent start-ingestion-job \ --knowledge-base-id ${{ steps.stack.outputs.kb_id }} \ diff --git a/.github/workflows/sync-notion.yml b/.github/workflows/sync-notion.yml index fbe5122..5ba0a07 100644 --- a/.github/workflows/sync-notion.yml +++ b/.github/workflows/sync-notion.yml @@ -28,29 +28,29 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Python 3.12 🐍 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - - name: Install dependencies + - name: Install dependencies 📦 run: pip install notion-exporter - - name: Export Notion pages to Markdown + - name: Export Notion pages to Markdown 📝 env: NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} run: python scripts/notion/export.py - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure AWS credentials (OIDC) 🔑 + uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" role-session-name: github-actions-sync-notion aws-region: ${{ env.AWS_REGION }} - - name: Get S3 bucket name from CloudFormation + - name: Get S3 bucket name from CloudFormation 📝 id: stack run: | BUCKET=$(aws cloudformation describe-stack-resource \ @@ -72,13 +72,13 @@ jobs: --output text) echo "ds_id=${DS_ID}" >> "$GITHUB_OUTPUT" - - name: Sync documents to S3 + - name: Sync documents to S3 📂 run: | aws s3 sync --delete \ build/${{ env.NOTION_PAGE_NAME }}/ \ s3://${{ steps.stack.outputs.bucket }}/documents/${{ env.NOTION_PAGE_NAME }}/ - - name: Start Knowledge Base ingestion + - name: Start Knowledge Base ingestion 🚀 run: | aws bedrock-agent start-ingestion-job \ --knowledge-base-id ${{ steps.stack.outputs.kb_id }} \ From 82447cbeb74bee19077f690027f41c673bac5ff3 Mon Sep 17 00:00:00 2001 From: nalbam Date: Sat, 18 Apr 2026 01:18:18 +0900 Subject: [PATCH 10/65] docs: Update CLAUDE.md and README.md for clarity and workflow details --- CLAUDE.md | 35 ++++++++++++++++++++++------------- README.md | 47 ++++++++++++++++------------------------------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b3de608..4aff7d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,10 +69,6 @@ and diverges from native function-calling semantics. async invocation, model choice, or streaming UX — not by stripping phases. -If a future change is tempted to add a keyword or rule-based intent -branch "just for images", the answer is no: route it through the -agent like everything else. - ## Architecture — the non-obvious parts ### Agent loop uses NATIVE function calling, not JSON prompting @@ -112,7 +108,7 @@ Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; ### Config is lazy, not import-time -`Settings.from_env()` runs at module load but does NOT validate Slack credentials. `Settings.require_slack_credentials()` is called from `_get_bolt_app()` so the first request fails cleanly but tests and tooling can import `app` without `SLACK_BOT_TOKEN`. The old `RuntimeError` at module top is gone. +`Settings.from_env()` runs at module load but does NOT validate Slack credentials. `Settings.require_slack_credentials()` is called from `_get_bolt_app()` so the first request fails cleanly, but tests and tooling can import `app` without `SLACK_BOT_TOKEN`. Enum/int validation quietly falls back to defaults with a warning: invalid `LLM_PROVIDER=mystery` → `openai`, `AGENT_MAX_STEPS=not-int` → `3`, below-minimum values clamp up. @@ -131,18 +127,31 @@ Stream throttling is handled inside `StreamingMessage.append()` (`min_interval=0 `serverless.yml` provisions: - Lambda: python3.12, x86_64, 5120MB, 90s timeout. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) - DynamoDB: hash `id`, GSI `user-index` (user + expire_at, KEYS_ONLY), TTL `expire_at`. -- IAM: `dynamodb:GetItem/PutItem/Query` on table + GSI, `bedrock:InvokeModel*`/`Converse*`. +- IAM (runtime Lambda role): `dynamodb:GetItem/PutItem/Query` on table + GSI, `bedrock:InvokeModel*`/`Converse*`. + +### GitHub Actions workflows + +Three files under `.github/workflows/`: + +- `push.yml` — on `push` to `main` (and `workflow_dispatch`). Runs `pytest --cov=src`, sets up Node 20 + Serverless v3, assumes the OIDC role `lambda-gurumi-bot`, then `serverless deploy --stage dev --region us-east-1`. +- `sync-notion.yml`, `sync-awsdocs.yml` — `workflow_dispatch` only (schedule commented out), each gated by `vars.ENABLE_SYNC_NOTION` / `ENABLE_SYNC_AWSDOCS`. Both call `aws cloudformation describe-stacks` expecting outputs `S3Bucket` / `KnowledgeBaseId` / `DataSourceId` that `serverless.yml` does not define, and invoke ingestion scripts (`scripts/notion/export.py`, `scripts/awsdocs/sync.sh`) that have been deleted. **They fail if enabled.** See "Excluded (Phase 2+)". -`.github/workflows/push-main.yml` runs pytest (with coverage), then `configure-aws-credentials` OIDC → `serverless deploy`. Secrets and Variables split described in README. +### OIDC role (`.github/aws-role/`) + +Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:awskrug/lambda-gurumi-bot:*` and `repo:nalbam/lambda-gurumi-bot:*`. `role-policy.json` is intentionally wider than current needs — it already grants `s3vectors:*`, `bedrock:*KnowledgeBase*`, `bedrock:*DataSource*`, `bedrock:*Agent*` (scoped to `lambda-gurumi-bot-*`) so Phase-2 KB work can land without IAM changes. ## Testing -Coverage target 80%+, currently 86% overall. Key approach: +125 tests, 86% overall coverage. `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: + - `moto[dynamodb]` for `DedupStore` / `ConversationStore` integration tests. - `responses` / `unittest.mock.patch("src.tools.urllib.request.urlopen")` for web tools. -- `ScriptedLLM` (see `tests/test_agent.py`) emits predefined `LLMResult` sequences to drive loop scenarios without any network. -- Provider tests use `MagicMock` clients (no real OpenAI / Bedrock calls). +- `ScriptedLLM` (in `tests/test_agent.py`) emits predefined `LLMResult` sequences to drive the agent loop without any network. +- Provider tests use `MagicMock` clients — no real OpenAI / Bedrock / xAI calls. - `tests/test_config.py` builds `Settings` from `monkeypatch`-controlled env without reloading the module. +- `reportlab` (dev-only) synthesizes real PDFs for `read_attached_document` parser coverage. + +Per-module coverage: `agent.py` 96%, `config.py` 98%, `tools.py` 90%, `llm.py` 84%, `slack_helpers.py` 83%, `dedup.py` 78%, `logging_utils.py` 68%. ## Things that are easy to break @@ -155,6 +164,6 @@ Coverage target 80%+, currently 86% overall. Key approach: ## Excluded (Phase 2+) -- Bedrock Knowledge Base (S3 Vectors + RAG) ingestion pipeline -- `reaction_added` event wiring + domain-specific handlers (refund masking, etc.) -- CloudWatch Alarms / X-Ray / multi-language prompts beyond ko/en +- **Bedrock Knowledge Base (S3 Vectors + RAG) ingestion pipeline.** Scaffolding exists (IAM policy + `sync-notion.yml` / `sync-awsdocs.yml`), but `serverless.yml` does not provision `S3Bucket` / `KnowledgeBase` / `DataSource`, and the ingestion scripts (`scripts/notion/export.py`, `scripts/awsdocs/sync.sh`) were removed. Both must be restored to re-enable the workflows. +- `reaction_added` event wiring and domain-specific handlers. +- CloudWatch Alarms, X-Ray tracing, languages other than `ko` / `en`. diff --git a/README.md b/README.md index d31f747..d19656f 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA - DynamoDB 조건부 put 으로 Slack 재시도 **중복 제거** - 채널 allowlist · 유저당 동시 요청 **throttle** - DynamoDB 기반 **스레드 대화 메모리** (TTL 1h) - - 긴 응답 **계층적 분할** 전송 (코드블록 → 문단 → 문장 → hard slice), `chat.update` 가 `msg_too_long` 에 걸리지 않도록 `MAX_LEN_SLACK` 기반 rolling 스트리밍 + 최종 답변 자동 split - - 스트리밍 `chat_postMessage` + 반복 `chat_update` fallback (네이티브 `chat.startStream`/`appendStream`/`stopStream` 은 AI 워크스페이스에서 추가 "searching" 상태 UI 를 띄워 두 개의 응답처럼 보이는 이슈 때문에 기본 비활성화, `enable_native=True` 로만 사용). **회신 데이터가 없을 때** (thinking / tool_use / tool_result 단계) 는 `assistant_threads_setStatus` 로 타이핑 인디케이터만 표시하고, **첫 content delta 가 도착한 시점에** 비로소 `stream_msg.start()` 를 지연 호출해 placeholder 메시지를 posting — 상태 UI 와 placeholder 가 동시에 뜨지 않아 두 개의 응답으로 보이던 이슈 해소. + - 긴 응답 **계층적 분할** 전송 (코드블록 → 문단 → 문장 → hard slice) + `MAX_LEN_SLACK` 기반 rolling 스트리밍 + - `chat_postMessage` + `chat_update` 반복으로 스트리밍 (네이티브 `chat.startStream` 계열은 `enable_native=True` 옵션). 툴 실행 구간에는 `assistant_threads_setStatus` 타이핑 인디케이터만 표시, **첫 content delta 도착 시점에** placeholder 메시지를 지연 posting — 상태 UI 와 placeholder 중복 표시 방지 - 구조화 JSON 로깅 + request_id, agent 루프 관찰값 기록 - 에러 메시지 sanitize (토큰·경로 redaction) @@ -59,7 +59,7 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA | `AWS_REGION` | | `us-east-1` | AWS 리전 | | `ALLOWED_CHANNEL_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 채널 허용 | | `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 | -| `MAX_LEN_SLACK` | | `3000` | 메시지 분할 기준 (≥500). Slack `chat.update` 의 한계 회피용 안전 margin. `.env.example` / `serverless.yml` 기본값. 변수 자체가 미설정이면 `config.py` fallback `2000`. | +| `MAX_LEN_SLACK` | | `3000` | 메시지 분할 기준 (≥500). `.env.example` · `serverless.yml` 기본 `3000`, 미지정 시 `config.py` 폴백 `2000`. | | `MAX_OUTPUT_TOKENS` | | `4096` | LLM hop 당 출력 토큰 상한 (≥256) | | `MAX_THROTTLE_COUNT` | | `100` | 유저별 동시 요청 상한 | | `MAX_HISTORY_CHARS` | | `4000` | 저장되는 대화 직렬화 최대 길이 | @@ -96,11 +96,12 @@ python localtest.py --no-stream "React 훅 설명해줘" # 전체 답변을 python localtest.py --quiet-steps "…" # 중간 step 로그 숨김 python localtest.py # 대화형 (stdin, Ctrl+D) -# 테스트 +# 테스트 (125 테스트, 커버리지 86% — `pytest.ini` 기준) python -m pytest --cov=src --cov-report=term-missing +python -m pytest tests/test_agent.py::test_agent_runs_tool_then_returns_text -v # 단일 케이스 ``` -`.env.local` 은 `src/config.py` 가 python-dotenv 로 자동 로드합니다. `SLACK_BOT_TOKEN` 이 placeholder 이면 `localtest.py` 가 Slack 호출을 stub 으로 대체합니다. +`.env.local` 은 `src/config.py` 가 python-dotenv 로 자동 로드합니다. `SLACK_BOT_TOKEN` 이 placeholder 이면 `localtest.py` 가 Slack 호출을 stub 으로 대체하고 `generate_image` 결과물은 `./.uploads/` 에 파일로 저장됩니다. ## 배포 (Serverless Framework v3) @@ -117,7 +118,7 @@ export ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account) aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/${NAME}" ``` -`trust-policy.json` 은 `nalbam/lambda-gurumi-bot` repo 의 OIDC 토큰을, `role-policy.json` 은 CloudFormation / Lambda / IAM / S3 / DynamoDB / API Gateway / CloudWatch Logs 권한을 (`lambda-gurumi-bot-*` 스코프) 포함합니다. +`trust-policy.json` 은 `awskrug/lambda-gurumi-bot` 과 `nalbam/lambda-gurumi-bot` repo 의 OIDC 토큰을, `role-policy.json` 은 CloudFormation · Lambda · IAM · S3 · DynamoDB · API Gateway · CloudWatch Logs · S3 Vectors · Bedrock (KnowledgeBase / DataSource / Agent) 권한을 `lambda-gurumi-bot-*` 스코프로 포함합니다. ### 2. GitHub 저장소 설정 @@ -126,7 +127,7 @@ aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${AC ### 3. 배포 -`main` 브랜치에 push 하면 `.github/workflows/push-main.yml` 이 pytest → Serverless deploy 순으로 수행합니다. 수동 실행은 `workflow_dispatch`. +`main` 브랜치에 push 하면 `.github/workflows/push.yml` 이 pytest (`--cov=src`) → Serverless v3 deploy 순으로 수행합니다. 수동 실행은 `workflow_dispatch`. ```bash # 로컬 배포 (선택) @@ -137,6 +138,14 @@ serverless deploy --stage dev --region us-east-1 DynamoDB 테이블 (해시키 `id`, GSI `user-index`, TTL `expire_at`) 은 CloudFormation 이 생성합니다. +### 4. 추가 워크플로 + +| 파일 | 역할 | 상태 | +|------|------|------| +| `push.yml` | 테스트 + Lambda 배포 | 활성 | +| `sync-notion.yml` | Notion → S3 → Bedrock KB ingestion | `workflow_dispatch` 전용, `vars.ENABLE_SYNC_NOTION == 'true'` gating. **Phase 2 미완성** (아래 참조) | +| `sync-awsdocs.yml` | AWS 공식 문서 → S3 → KB ingestion | 위와 동일 패턴, `ENABLE_SYNC_AWSDOCS` gating | + ## 아키텍처 ``` @@ -189,27 +198,3 @@ DynamoDB 테이블 (해시키 `id`, GSI `user-index`, TTL `expire_at`) 은 Cloud │ (dedup+ctx)│ └────────────┘ ``` - -## Lambda 엔트리포인트 - -- `app.lambda_handler` - -## Production 체크리스트 (수동 확인용) - -- [ ] `@` 멘션에 정상 응답 -- [ ] DM 대화에 응답 -- [ ] 긴 응답이 여러 청크로 쪼개져 스레드에 전송됨 -- [ ] 이미지 생성 요청 (`"고양이 그려줘"`) 이 업로드됨 -- [ ] 이미지 첨부 요약 (`read_attached_images`) 동작 -- [ ] PDF/텍스트 첨부 요약 (`read_attached_document`) 이 페이지·문자 상한 안에서 동작 (암호화 PDF 는 오류 반환) -- [ ] `ALLOWED_CHANNEL_IDS` 외 채널에서 차단 메시지 표시 -- [ ] Slack retry 중복 호출이 dedup 로 무시됨 (CloudWatch 로그에 `dedup.skip` 확인) -- [ ] 툴 실행 중 타이핑 인디케이터 (`assistant_threads_setStatus`) 만 뜨고 placeholder 메시지는 아직 올라오지 않음 (회신 데이터가 없을 때) -- [ ] 회신 생성이 시작되면 단일 메시지가 올라와 스트리밍으로 업데이트 됨 (상태 UI 와 placeholder 가 동시에 보이지 않음) -- [ ] 같은 스레드 재멘션 시 이전 대화 맥락 참조 - -## 제외된 항목 (Phase 2 이상) - -- Bedrock Knowledge Base (RAG) 통합 -- `reaction_added` 이벤트 훅 및 도메인 특화 로직 -- CloudWatch Alarms / X-Ray tracing From 875035ff1fa423f4b0978c412378ed184a10f9a2 Mon Sep 17 00:00:00 2001 From: Jungyoul Yu Date: Sat, 18 Apr 2026 02:07:59 +0900 Subject: [PATCH 11/65] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index d19656f..995183f 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,6 @@ export ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account) aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/${NAME}" ``` -`trust-policy.json` 은 `awskrug/lambda-gurumi-bot` 과 `nalbam/lambda-gurumi-bot` repo 의 OIDC 토큰을, `role-policy.json` 은 CloudFormation · Lambda · IAM · S3 · DynamoDB · API Gateway · CloudWatch Logs · S3 Vectors · Bedrock (KnowledgeBase / DataSource / Agent) 권한을 `lambda-gurumi-bot-*` 스코프로 포함합니다. - ### 2. GitHub 저장소 설정 - **Secrets**: `AWS_ACCOUNT_ID`, `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `OPENAI_API_KEY`, `XAI_API_KEY`(xAI 사용 시), `TAVILY_API_KEY`(선택) From 165d886ff43b13a77a8b01a4047ccf31d1819f6b Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 11:39:00 +0900 Subject: [PATCH 12/65] refactor: Remove path instructions from review configuration --- .coderabbit.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 5e76b96..35576ac 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -12,12 +12,5 @@ reviews: enabled: true drafts: false path_filters: ['!cassettes/**'] - path_instructions: - - path: 'src/usecases/**/*.ts' - instructions: 'Clean Architecture 원칙과 ServiceResult 패턴을 따르는지 확인' - - path: '**/*.test.ts' - instructions: 'AAA 패턴(Arrange-Act-Assert)과 한국어 테스트 설명을 확인' - - path: 'src/domains/**/*.ts' - instructions: '도메인 모델이 순수하고 외부 의존성이 없는지 확인' chat: auto_reply: true From 0e385a32c3b850559f86a7a5eb013af99891b75f Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:22:49 +0900 Subject: [PATCH 13/65] feat(config): add web-fetch settings (MAX_WEB_CHARS/BYTES/LINKS, JINA_READER_BASE) Co-Authored-By: Claude Sonnet 4.6 --- src/config.py | 9 +++++++++ tests/test_config.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/config.py b/src/config.py index 61e03c5..7f1d93a 100644 --- a/src/config.py +++ b/src/config.py @@ -97,6 +97,10 @@ class Settings: max_doc_chars: int = 20_000 max_doc_pages: int = 50 max_doc_bytes: int = 25 * 1024 * 1024 + max_web_chars: int = 8000 + max_web_bytes: int = 2 * 1024 * 1024 + max_web_links: int = 20 + jina_reader_base: str = "https://r.jina.ai" @classmethod def from_env(cls) -> "Settings": @@ -136,6 +140,11 @@ def from_env(cls) -> "Settings": max_doc_chars=_int_env("MAX_DOC_CHARS", 20_000, minimum=1000), max_doc_pages=_int_env("MAX_DOC_PAGES", 50, minimum=1), max_doc_bytes=_int_env("MAX_DOC_BYTES", 25 * 1024 * 1024, minimum=64 * 1024), + max_web_chars=_int_env("MAX_WEB_CHARS", 8000, minimum=500), + max_web_bytes=_int_env("MAX_WEB_BYTES", 2 * 1024 * 1024, minimum=64 * 1024), + max_web_links=_int_env("MAX_WEB_LINKS", 20, minimum=0), + jina_reader_base=(os.getenv("JINA_READER_BASE", "https://r.jina.ai").strip() + or "https://r.jina.ai"), ) def require_slack_credentials(self) -> None: diff --git a/tests/test_config.py b/tests/test_config.py index 250adc3..48ecf4b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,6 +21,7 @@ def _clear_env(monkeypatch): "ALLOWED_CHANNEL_MESSAGE", "MAX_LEN_SLACK", "MAX_THROTTLE_COUNT", "MAX_HISTORY_CHARS", "BOT_CURSOR", "SYSTEM_MESSAGE", "TAVILY_API_KEY", "XAI_API_KEY", "LOG_LEVEL", "DEFAULT_TIMEZONE", "MAX_DOC_CHARS", "MAX_DOC_PAGES", "MAX_DOC_BYTES", + "MAX_WEB_CHARS", "MAX_WEB_BYTES", "MAX_WEB_LINKS", "JINA_READER_BASE", ]: monkeypatch.delenv(key, raising=False) @@ -144,3 +145,36 @@ def test_default_timezone_custom_value(monkeypatch, reload_config): monkeypatch.setenv("DEFAULT_TIMEZONE", "America/New_York") s = reload_config() assert s.default_timezone == "America/New_York" + + +def test_web_fetch_defaults(monkeypatch, reload_config): + _clear_env(monkeypatch) + for key in ["MAX_WEB_CHARS", "MAX_WEB_BYTES", "MAX_WEB_LINKS", "JINA_READER_BASE"]: + monkeypatch.delenv(key, raising=False) + s = reload_config() + assert s.max_web_chars == 8000 + assert s.max_web_bytes == 2 * 1024 * 1024 + assert s.max_web_links == 20 + assert s.jina_reader_base == "https://r.jina.ai" + + +def test_web_fetch_env_overrides(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_WEB_CHARS", "1000") + monkeypatch.setenv("MAX_WEB_BYTES", "131072") # 128KB + monkeypatch.setenv("MAX_WEB_LINKS", "5") + monkeypatch.setenv("JINA_READER_BASE", "https://custom.reader.example") + s = reload_config() + assert s.max_web_chars == 1000 + assert s.max_web_bytes == 131072 + assert s.max_web_links == 5 + assert s.jina_reader_base == "https://custom.reader.example" + + +def test_web_fetch_below_minimum_clamped(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("MAX_WEB_CHARS", "10") # below 500 floor + monkeypatch.setenv("MAX_WEB_BYTES", "1024") # below 64KB floor + s = reload_config() + assert s.max_web_chars == 500 + assert s.max_web_bytes == 64 * 1024 From 03d51bf1b19ab9d1b36c921dd2dcd88e24bfd4ea Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:26:21 +0900 Subject: [PATCH 14/65] test(config): drop redundant delenv loop in test_web_fetch_defaults --- tests/test_config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 48ecf4b..8008fc7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -149,8 +149,6 @@ def test_default_timezone_custom_value(monkeypatch, reload_config): def test_web_fetch_defaults(monkeypatch, reload_config): _clear_env(monkeypatch) - for key in ["MAX_WEB_CHARS", "MAX_WEB_BYTES", "MAX_WEB_LINKS", "JINA_READER_BASE"]: - monkeypatch.delenv(key, raising=False) s = reload_config() assert s.max_web_chars == 8000 assert s.max_web_bytes == 2 * 1024 * 1024 From 9bc676c9db8f94c839e300305cfe97534a06053a Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:28:37 +0900 Subject: [PATCH 15/65] feat(tools): add SSRF guard and no-redirect handler for web fetch Co-Authored-By: Claude Sonnet 4.6 --- src/tools.py | 58 +++++++++++++++++++++++++++++++++++ tests/test_tools.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/tools.py b/src/tools.py index 74bf915..dd14962 100644 --- a/src/tools.py +++ b/src/tools.py @@ -7,8 +7,10 @@ """ from __future__ import annotations +import ipaddress import json import logging +import socket import time import urllib.error import urllib.parse @@ -618,3 +620,59 @@ def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]] ] +_PUBLIC_WEB_UA = "lambda-gurumi-bot/1.0 (+https://github.com/nalbam/lambda-gurumi-bot)" + + +def _validate_public_https_url(url: str) -> tuple[str, str]: + """Return (scheme, hostname) after asserting the URL is safe to fetch. + + Rules: + - scheme == 'https' + - hostname is present and is NOT an IP literal + - every address returned by getaddrinfo for the hostname is a public, + routable unicast address (not private / loopback / link-local / + reserved / multicast / unspecified). + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise ValueError("fetch_webpage requires https") + host = parsed.hostname + if not host: + raise ValueError("URL missing hostname") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise ValueError("IP literals not allowed") + try: + infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise ValueError(f"DNS resolution failed: {exc}") from exc + for info in infos: + addr = ipaddress.ip_address(info[4][0]) + if ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_reserved + or addr.is_multicast + or addr.is_unspecified + ): + raise ValueError("hostname resolves to non-public address") + return parsed.scheme, host + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """urllib handler that refuses to follow redirects. + + Raw fetches must hit exactly the host whose DNS we pre-validated. A 3xx + pointing at a private host would silently defeat the SSRF guard. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise urllib.error.HTTPError( + req.full_url, code, "redirects not allowed", headers, fp + ) + + diff --git a/tests/test_tools.py b/tests/test_tools.py index 2e4e381..648ac2e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -738,3 +738,76 @@ def test_read_attached_document_http_error_returns_per_item(): assert len(out) == 1 assert "error" in out[0] assert "404" in out[0]["error"] + + +# --------------------------------------------------------------------------- # +# fetch_webpage — SSRF guard +# --------------------------------------------------------------------------- # + + +def test_validate_public_https_url_rejects_http_scheme(): + from src.tools import _validate_public_https_url + + with pytest.raises(ValueError, match="https"): + _validate_public_https_url("http://example.com/") + + +def test_validate_public_https_url_rejects_ip_literal_v4(): + from src.tools import _validate_public_https_url + + with pytest.raises(ValueError, match="IP literals"): + _validate_public_https_url("https://127.0.0.1/") + + +def test_validate_public_https_url_rejects_ip_literal_v6(): + from src.tools import _validate_public_https_url + + with pytest.raises(ValueError, match="IP literals"): + _validate_public_https_url("https://[::1]/") + + +def test_validate_public_https_url_rejects_private_dns(monkeypatch): + from src.tools import _validate_public_https_url + + def fake_getaddrinfo(host, port, type=None, *args, **kwargs): + # Simulate DNS pointing at RFC1918 space. + return [(None, None, None, "", ("10.0.0.1", port))] + + monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="non-public"): + _validate_public_https_url("https://internal.corp.example/") + + +def test_validate_public_https_url_rejects_metadata_host(monkeypatch): + from src.tools import _validate_public_https_url + + def fake_getaddrinfo(host, port, type=None, *args, **kwargs): + return [(None, None, None, "", ("169.254.169.254", port))] + + monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="non-public"): + _validate_public_https_url("https://cloud.metadata.example/") + + +def test_validate_public_https_url_accepts_public_host(monkeypatch): + from src.tools import _validate_public_https_url + + def fake_getaddrinfo(host, port, type=None, *args, **kwargs): + return [(None, None, None, "", ("93.184.216.34", port))] # example.com + + monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + scheme, host = _validate_public_https_url("https://example.com/path") + assert scheme == "https" + assert host == "example.com" + + +def test_no_redirect_handler_raises_on_302(): + import urllib.error + import urllib.request + + from src.tools import _NoRedirectHandler + + handler = _NoRedirectHandler() + req = urllib.request.Request("https://example.com/") + with pytest.raises(urllib.error.HTTPError): + handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example/") From 302f1434873d44316023d706b8a45643fd2a4180 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:36:09 +0900 Subject: [PATCH 16/65] fix(tools): strengthen SSRF guard (empty DNS, CGNAT, gaierror test) Co-Authored-By: Claude Sonnet 4.6 --- src/tools.py | 3 +++ tests/test_tools.py | 21 +++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/tools.py b/src/tools.py index dd14962..c7812db 100644 --- a/src/tools.py +++ b/src/tools.py @@ -649,6 +649,8 @@ def _validate_public_https_url(url: str) -> tuple[str, str]: infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) except socket.gaierror as exc: raise ValueError(f"DNS resolution failed: {exc}") from exc + if not infos: + raise ValueError("DNS resolution returned no addresses") for info in infos: addr = ipaddress.ip_address(info[4][0]) if ( @@ -658,6 +660,7 @@ def _validate_public_https_url(url: str) -> tuple[str, str]: or addr.is_reserved or addr.is_multicast or addr.is_unspecified + or not addr.is_global ): raise ValueError("hostname resolves to non-public address") return parsed.scheme, host diff --git a/tests/test_tools.py b/tests/test_tools.py index 648ac2e..9f6b014 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -769,7 +769,7 @@ def test_validate_public_https_url_rejects_ip_literal_v6(): def test_validate_public_https_url_rejects_private_dns(monkeypatch): from src.tools import _validate_public_https_url - def fake_getaddrinfo(host, port, type=None, *args, **kwargs): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): # Simulate DNS pointing at RFC1918 space. return [(None, None, None, "", ("10.0.0.1", port))] @@ -781,7 +781,7 @@ def fake_getaddrinfo(host, port, type=None, *args, **kwargs): def test_validate_public_https_url_rejects_metadata_host(monkeypatch): from src.tools import _validate_public_https_url - def fake_getaddrinfo(host, port, type=None, *args, **kwargs): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("169.254.169.254", port))] monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) @@ -792,7 +792,7 @@ def fake_getaddrinfo(host, port, type=None, *args, **kwargs): def test_validate_public_https_url_accepts_public_host(monkeypatch): from src.tools import _validate_public_https_url - def fake_getaddrinfo(host, port, type=None, *args, **kwargs): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("93.184.216.34", port))] # example.com monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) @@ -801,6 +801,19 @@ def fake_getaddrinfo(host, port, type=None, *args, **kwargs): assert host == "example.com" +def test_validate_public_https_url_dns_failure(monkeypatch): + import socket as _socket + + from src.tools import _validate_public_https_url + + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + raise _socket.gaierror("nodename nor servname provided") + + monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="DNS resolution failed"): + _validate_public_https_url("https://nonexistent.invalid.example/") + + def test_no_redirect_handler_raises_on_302(): import urllib.error import urllib.request @@ -809,5 +822,5 @@ def test_no_redirect_handler_raises_on_302(): handler = _NoRedirectHandler() req = urllib.request.Request("https://example.com/") - with pytest.raises(urllib.error.HTTPError): + with pytest.raises(urllib.error.HTTPError, match="redirects not allowed"): handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example/") From 3aeb52664e2e83fae2c0f4393d485ee64cd4d212 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:39:02 +0900 Subject: [PATCH 17/65] feat(tools): add HTML extractor and Jina response parser for web fetch --- src/tools.py | 131 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_tools.py | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) diff --git a/src/tools.py b/src/tools.py index c7812db..4b15384 100644 --- a/src/tools.py +++ b/src/tools.py @@ -10,11 +10,13 @@ import ipaddress import json import logging +import re import socket import time import urllib.error import urllib.parse import urllib.request +from html.parser import HTMLParser from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from dataclasses import dataclass, field from typing import Any, Callable @@ -679,3 +681,132 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): ) +_JINA_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") + + +class _HtmlTextExtractor(HTMLParser): + """Streams visible text + links out of raw HTML. + + Skips content inside script/style/noscript/template. Breaks paragraphs + on block-level end tags. Link hrefs are resolved against ``base_url``; + filtering (https-only, dedup, self-ref drop) is left to ``_filter_links``. + """ + + _SKIP_TAGS = {"script", "style", "noscript", "template"} + _BREAK_TAGS = {"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br", "tr"} + + def __init__(self, base_url: str): + super().__init__(convert_charrefs=True) + self._base_url = base_url + self._skip_depth = 0 + self._text_chunks: list[str] = [] + self._title_chunks: list[str] = [] + self._in_title = False + self._current_link_url: str | None = None + self._current_link_text: list[str] = [] + self.links: list[tuple[str, str]] = [] + + def handle_starttag(self, tag, attrs): + if tag in self._SKIP_TAGS: + self._skip_depth += 1 + return + if tag == "title": + self._in_title = True + return + if tag == "a": + href = dict(attrs).get("href") + if href: + self._current_link_url = urllib.parse.urljoin(self._base_url, href) + self._current_link_text = [] + + def handle_endtag(self, tag): + if tag in self._SKIP_TAGS: + if self._skip_depth > 0: + self._skip_depth -= 1 + return + if tag == "title": + self._in_title = False + return + if tag == "a" and self._current_link_url is not None: + text = " ".join("".join(self._current_link_text).split()) + self.links.append((text, self._current_link_url)) + self._current_link_url = None + self._current_link_text = [] + return + if tag in self._BREAK_TAGS: + self._text_chunks.append("\n") + + def handle_data(self, data): + if self._skip_depth > 0: + return + if self._in_title: + self._title_chunks.append(data) + return + if self._current_link_url is not None: + self._current_link_text.append(data) + self._text_chunks.append(data) + + def title(self) -> str: + return " ".join("".join(self._title_chunks).split()) + + def text(self) -> str: + joined = "".join(self._text_chunks) + lines = [" ".join(line.split()) for line in joined.split("\n")] + return "\n".join(line for line in lines if line) + + +def _filter_links( + raw: list[tuple[str, str]], base_url: str, limit: int +) -> list[dict[str, str]]: + if limit <= 0: + return [] + seen: set[str] = set() + out: list[dict[str, str]] = [] + for text, url in raw: + if not url.startswith("https://"): + continue + parsed = urllib.parse.urlparse(url) + if not parsed.netloc: + continue + key = url.split("#", 1)[0] + if key == base_url.split("#", 1)[0]: + continue + if key in seen: + continue + seen.add(key) + out.append({"title": (text or url).strip(), "url": url}) + if len(out) >= limit: + break + return out + + +def _extract_markdown_links( + md: str, base_url: str, limit: int +) -> list[dict[str, str]]: + raw = [(m.group(1), m.group(2)) for m in _JINA_LINK_RE.finditer(md)] + return _filter_links(raw, base_url, limit) + + +def _parse_jina_response(text: str) -> tuple[str, str]: + """Split the Jina Reader preamble ("Title:", "URL Source:", "Markdown + Content:") from the body. Returns (title, body).""" + if not text: + return "", "" + all_lines = text.split("\n") + title = "" + body_start = 0 + for i, line in enumerate(all_lines[:10]): + if line.startswith("Title: "): + title = line[len("Title: "):].strip() + body_start = max(body_start, i + 1) + elif line.startswith("URL Source: "): + body_start = max(body_start, i + 1) + elif line.startswith("Markdown Content:"): + body_start = i + 1 + break + if body_start == 0: + return title, text + body = "\n".join(all_lines[body_start:]).lstrip("\n") + return title, body + + diff --git a/tests/test_tools.py b/tests/test_tools.py index 9f6b014..c125918 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -824,3 +824,111 @@ def test_no_redirect_handler_raises_on_302(): req = urllib.request.Request("https://example.com/") with pytest.raises(urllib.error.HTTPError, match="redirects not allowed"): handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example/") + + +# --------------------------------------------------------------------------- # +# fetch_webpage — HTML parser + Jina response parser +# --------------------------------------------------------------------------- # + + +def test_html_text_extractor_basic(): + from src.tools import _HtmlTextExtractor + + html = ( + " Hello " + "" + "" + "" + "

Heading

" + "

Para one.

" + "

Para two.

" + "
Link A" + "Rel" + "Frag" + "Mail" + "" + ) + x = _HtmlTextExtractor("https://base.example/page") + x.feed(html) + assert x.title() == "Hello" + text = x.text() + assert "Heading" in text + assert "Para one." in text + assert "Para two." in text + assert "alert" not in text + assert ".a{}" not in text + assert ("Link A", "https://a.example/1") in x.links + # Relative links resolved against base + assert ("Rel", "https://base.example/relative") in x.links + # mailto / fragment retained raw — filtering happens in _filter_links + assert any(url.startswith("mailto:") for _, url in x.links) + + +def test_filter_links_drops_non_https_and_dedups(): + from src.tools import _filter_links + + raw = [ + ("A", "https://a.example/1"), + ("A dup", "https://a.example/1#top"), # dedups by fragment-stripped url + ("B", "https://b.example/"), + ("Self", "https://base.example/page"), # self-ref dropped + ("Mail", "mailto:x@y"), + ("JS", "javascript:void(0)"), + ("HTTP", "http://insecure.example/"), + ] + out = _filter_links(raw, base_url="https://base.example/page", limit=10) + urls = [item["url"] for item in out] + assert urls == ["https://a.example/1", "https://b.example/"] + assert out[0]["title"] == "A" + + +def test_filter_links_respects_limit(): + from src.tools import _filter_links + + raw = [(f"T{i}", f"https://x.example/{i}") for i in range(5)] + out = _filter_links(raw, base_url="https://base.example/", limit=3) + assert len(out) == 3 + assert [item["url"] for item in out] == [ + "https://x.example/0", + "https://x.example/1", + "https://x.example/2", + ] + + +def test_extract_markdown_links_parses_inline_markdown(): + from src.tools import _extract_markdown_links + + md = ( + "Title\n\nSome prose with [Google](https://google.com/about) " + "and [Self](https://base.example/page) and [Same](https://google.com/about?ref=x).\n" + "[Another](https://example.org/)" + ) + out = _extract_markdown_links(md, base_url="https://base.example/page", limit=10) + urls = [item["url"] for item in out] + assert "https://google.com/about" in urls + assert "https://example.org/" in urls + assert "https://base.example/page" not in urls # self-ref dropped + + +def test_parse_jina_response_strips_header(): + from src.tools import _parse_jina_response + + payload = ( + "Title: Example Page\n" + "URL Source: https://example.com/\n" + "Markdown Content:\n" + "# Heading\n\nBody text.\n" + ) + title, body = _parse_jina_response(payload) + assert title == "Example Page" + assert body.startswith("# Heading") + assert "URL Source" not in body + + +def test_parse_jina_response_no_header(): + from src.tools import _parse_jina_response + + payload = "just raw markdown\n\nwith no prefix" + title, body = _parse_jina_response(payload) + assert title == "" + assert body == payload From 3846fb05bdbb1f1aca4be69a1e4166e0faed00c8 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:47:00 +0900 Subject: [PATCH 18/65] fix(tools): harden Jina/HTML parsers (inline body, balanced parens, host normalization) --- src/tools.py | 34 +++++++++++++++++++++----- tests/test_tools.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/tools.py b/src/tools.py index 4b15384..8a19953 100644 --- a/src/tools.py +++ b/src/tools.py @@ -681,7 +681,10 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): ) -_JINA_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") +_JINA_LINK_RE = re.compile( + r"(?: the inner link overwrites the outer one. Matches + # how HTML5 parsers implicitly close an unclosed ; the outer + # link is lost but the inner (usually more specific) is kept. href = dict(attrs).get("href") if href: self._current_link_url = urllib.parse.urljoin(self._base_url, href) @@ -760,6 +766,12 @@ def _filter_links( ) -> list[dict[str, str]]: if limit <= 0: return [] + base_parsed = urllib.parse.urlparse(base_url) + base_key = base_parsed._replace( + scheme=base_parsed.scheme.lower(), + netloc=base_parsed.netloc.lower(), + fragment="", + ).geturl() seen: set[str] = set() out: list[dict[str, str]] = [] for text, url in raw: @@ -768,12 +780,17 @@ def _filter_links( parsed = urllib.parse.urlparse(url) if not parsed.netloc: continue - key = url.split("#", 1)[0] - if key == base_url.split("#", 1)[0]: + # Normalize host case for comparison (scheme+host are case-insensitive) + normalized = parsed._replace( + scheme=parsed.scheme.lower(), + netloc=parsed.netloc.lower(), + fragment="", + ).geturl() + if normalized == base_key: continue - if key in seen: + if normalized in seen: continue - seen.add(key) + seen.add(normalized) out.append({"title": (text or url).strip(), "url": url}) if len(out) >= limit: break @@ -795,13 +812,18 @@ def _parse_jina_response(text: str) -> tuple[str, str]: all_lines = text.split("\n") title = "" body_start = 0 - for i, line in enumerate(all_lines[:10]): + for i, line in enumerate(all_lines[:_JINA_HEADER_MAX_LINES]): if line.startswith("Title: "): title = line[len("Title: "):].strip() body_start = max(body_start, i + 1) elif line.startswith("URL Source: "): body_start = max(body_start, i + 1) elif line.startswith("Markdown Content:"): + inline = line[len("Markdown Content:"):].strip() + if inline: + # inline content on same line as the marker: keep it + body_lines = [inline] + all_lines[i + 1:] + return title, "\n".join(body_lines).lstrip("\n") body_start = i + 1 break if body_start == 0: diff --git a/tests/test_tools.py b/tests/test_tools.py index c125918..c76aac6 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -932,3 +932,61 @@ def test_parse_jina_response_no_header(): title, body = _parse_jina_response(payload) assert title == "" assert body == payload + + +def test_html_text_extractor_empty(): + from src.tools import _HtmlTextExtractor + + x = _HtmlTextExtractor("https://base.example/") + x.feed("") + assert x.title() == "" + assert x.text() == "" + assert x.links == [] + + +def test_filter_links_host_case_normalization(): + from src.tools import _filter_links + + out = _filter_links( + [ + ("A", "https://Example.COM/path"), + ("B", "https://example.com/path"), # dup by host case + ("Self", "https://BASE.EXAMPLE/page"), # self-ref by host case + ], + base_url="https://base.example/page", + limit=10, + ) + urls = [item["url"] for item in out] + assert urls == ["https://Example.COM/path"] # first-seen wins, case preserved in output + + +def test_extract_markdown_links_skips_images(): + from src.tools import _extract_markdown_links + + md = ( + "![logo](https://img.example/logo.png) " + "see [Docs](https://docs.example/) for more" + ) + out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) + urls = [item["url"] for item in out] + assert "https://img.example/logo.png" not in urls + assert "https://docs.example/" in urls + + +def test_extract_markdown_links_preserves_paren_in_url(): + from src.tools import _extract_markdown_links + + md = "see [Wiki](https://en.wikipedia.org/wiki/Foo_(bar)) for context" + out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) + urls = [item["url"] for item in out] + assert "https://en.wikipedia.org/wiki/Foo_(bar)" in urls + + +def test_parse_jina_response_inline_markdown_content(): + from src.tools import _parse_jina_response + + payload = "Title: T\nURL Source: https://example.com/\nMarkdown Content: # Heading\n\nBody here." + title, body = _parse_jina_response(payload) + assert title == "T" + assert body.startswith("# Heading") + assert "Body here." in body From ab5c44991f4ac834f1cb53e82b3bebdefc54c9ef Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:49:28 +0900 Subject: [PATCH 19/65] feat(tools): add Jina Reader + direct-GET fetchers with size guards --- src/tools.py | 44 +++++++++++++++++++++++ tests/test_tools.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/src/tools.py b/src/tools.py index 8a19953..ae7f293 100644 --- a/src/tools.py +++ b/src/tools.py @@ -804,6 +804,50 @@ def _extract_markdown_links( return _filter_links(raw, base_url, limit) +def _read_body_capped(response, max_bytes: int) -> bytes: + content_length = response.headers.get("Content-Length") if response.headers else None + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") + body = response.read(max_bytes + 1) + if len(body) > max_bytes: + raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") + return body + + +def _jina_fetch(base: str, target_url: str, max_bytes: int) -> str: + """Call Jina Reader and return the markdown body as text. + + Size gate is identical to the raw path; oversize responses raise so the + caller can fall through to a direct fetch (which may be smaller). + """ + quoted = urllib.parse.quote(target_url, safe=":/?#[]@!$&'()*+,;=") + endpoint = f"{base.rstrip('/')}/{quoted}" + req = urllib.request.Request( + endpoint, + headers={ + "Accept": "text/markdown", + "User-Agent": _PUBLIC_WEB_UA, + "X-Return-Format": "markdown", + }, + ) + with urllib.request.urlopen(req, timeout=12) as response: # noqa: S310 (URL built from validated base + percent-encoded target) + body = _read_body_capped(response, max_bytes) + return body.decode("utf-8", errors="replace") + + +def _raw_fetch(url: str, max_bytes: int) -> str: + """Direct GET on the target URL with redirects disabled. + + Caller is expected to have passed ``url`` through + ``_validate_public_https_url`` so the connection target is public. + """ + opener = urllib.request.build_opener(_NoRedirectHandler()) + req = urllib.request.Request(url, headers={"User-Agent": _PUBLIC_WEB_UA}) + with opener.open(req, timeout=12) as response: # noqa: S310 + body = _read_body_capped(response, max_bytes) + return body.decode("utf-8", errors="replace") + + def _parse_jina_response(text: str) -> tuple[str, str]: """Split the Jina Reader preamble ("Title:", "URL Source:", "Markdown Content:") from the body. Returns (title, body).""" diff --git a/tests/test_tools.py b/tests/test_tools.py index c76aac6..d821851 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -990,3 +990,89 @@ def test_parse_jina_response_inline_markdown_content(): assert title == "T" assert body.startswith("# Heading") assert "Body here." in body + + +# --------------------------------------------------------------------------- # +# fetch_webpage — fetch helpers +# --------------------------------------------------------------------------- # + + +def _streamed_read(body: bytes): + """Build a urlopen-mock read side_effect that serves `body` in chunks.""" + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + return _chunked + + +def test_jina_fetch_returns_body_under_cap(): + from src.tools import _jina_fetch + + payload = b"Title: x\nURL Source: https://example.com/\n\nBody here." + with patch("src.tools.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(payload)), "Content-Type": "text/markdown"} + resp.read.side_effect = _streamed_read(payload) + text = _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + assert "Body here." in text + + +def test_jina_fetch_content_length_over_cap(): + from src.tools import _jina_fetch + + with patch("src.tools.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": "999999"} + resp.read.side_effect = _streamed_read(b"x" * 10) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + + +def test_jina_fetch_streamed_over_cap(): + from src.tools import _jina_fetch + + body = b"x" * 2000 + with patch("src.tools.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {} # no Content-Length + resp.read.side_effect = _streamed_read(body) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + + +def test_raw_fetch_returns_body_under_cap(monkeypatch): + from src.tools import _raw_fetch + + body = b"Hihello" + + # _raw_fetch builds its own opener (with _NoRedirectHandler) and calls opener.open. + # Patch urllib.request.build_opener so we intercept that call. + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(body)), "Content-Type": "text/html"} + cm.read.side_effect = _streamed_read(body) + monkeypatch.setattr("src.tools.urllib.request.build_opener", lambda *_: fake_opener) + + html = _raw_fetch("https://example.com/", max_bytes=1024) + assert "hello" in html + + +def test_raw_fetch_streamed_over_cap(monkeypatch): + from src.tools import _raw_fetch + + body = b"a" * 4096 + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {} + cm.read.side_effect = _streamed_read(body) + monkeypatch.setattr("src.tools.urllib.request.build_opener", lambda *_: fake_opener) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _raw_fetch("https://example.com/", max_bytes=1024) From a1e31c87a0f0d6d4a0fd8d4e3df06f72d7da087b Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:53:15 +0900 Subject: [PATCH 20/65] refactor(tools): extract web fetch timeout constant and annotate noqa --- src/tools.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tools.py b/src/tools.py index ae7f293..5691d50 100644 --- a/src/tools.py +++ b/src/tools.py @@ -623,6 +623,7 @@ def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]] _PUBLIC_WEB_UA = "lambda-gurumi-bot/1.0 (+https://github.com/nalbam/lambda-gurumi-bot)" +_WEB_FETCH_TIMEOUT = 12 # Jina Reader + direct raw GET; capped shorter than Slack internal fetch (15s) because web targets have no retry budget inside the tool. def _validate_public_https_url(url: str) -> tuple[str, str]: @@ -830,7 +831,7 @@ def _jina_fetch(base: str, target_url: str, max_bytes: int) -> str: "X-Return-Format": "markdown", }, ) - with urllib.request.urlopen(req, timeout=12) as response: # noqa: S310 (URL built from validated base + percent-encoded target) + with urllib.request.urlopen(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL built from validated base + percent-encoded target) body = _read_body_capped(response, max_bytes) return body.decode("utf-8", errors="replace") @@ -843,7 +844,7 @@ def _raw_fetch(url: str, max_bytes: int) -> str: """ opener = urllib.request.build_opener(_NoRedirectHandler()) req = urllib.request.Request(url, headers={"User-Agent": _PUBLIC_WEB_UA}) - with opener.open(req, timeout=12) as response: # noqa: S310 + with opener.open(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL pre-validated by _validate_public_https_url; redirects disabled by _NoRedirectHandler) body = _read_body_capped(response, max_bytes) return body.decode("utf-8", errors="replace") From 7ad31fb588a680975ae3d4480945168c6d835827 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 14:58:19 +0900 Subject: [PATCH 21/65] refactor(tools): split web-fetch helpers into src/tools_web.py Move _PUBLIC_WEB_UA, _WEB_FETCH_TIMEOUT, _JINA_HEADER_MAX_LINES, _JINA_LINK_RE, _HtmlTextExtractor, _NoRedirectHandler, _validate_public_https_url, _filter_links, _extract_markdown_links, _parse_jina_response, _read_body_capped, _jina_fetch, _raw_fetch from src/tools.py into a new src/tools_web.py. Re-export all moved symbols from src/tools for backward compatibility. Drop unused imports (ipaddress, re, socket, HTMLParser) from src/tools.py. Update monkeypatch targets in tests/test_tools.py for the moved helpers (src.tools_web.socket.getaddrinfo, src.tools_web.urllib.request.urlopen/build_opener). Co-Authored-By: Claude Sonnet 4.6 --- src/tools.py | 276 +++----------------------------------------- src/tools_web.py | 270 +++++++++++++++++++++++++++++++++++++++++++ tests/test_tools.py | 18 +-- 3 files changed, 297 insertions(+), 267 deletions(-) create mode 100644 src/tools_web.py diff --git a/src/tools.py b/src/tools.py index 5691d50..8eed557 100644 --- a/src/tools.py +++ b/src/tools.py @@ -7,16 +7,12 @@ """ from __future__ import annotations -import ipaddress import json import logging -import re -import socket import time import urllib.error import urllib.parse import urllib.request -from html.parser import HTMLParser from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from dataclasses import dataclass, field from typing import Any, Callable @@ -28,6 +24,24 @@ from src.llm import LLMProvider, ToolCall from src.slack_helpers import user_name_cache +# Re-export web-fetch helpers so callers (and tests that still import from +# src.tools) keep working while the web-fetch module lives in tools_web.py. +from src.tools_web import ( + _HtmlTextExtractor, + _JINA_HEADER_MAX_LINES, + _JINA_LINK_RE, + _NoRedirectHandler, + _PUBLIC_WEB_UA, + _WEB_FETCH_TIMEOUT, + _extract_markdown_links, + _filter_links, + _jina_fetch, + _parse_jina_response, + _raw_fetch, + _read_body_capped, + _validate_public_https_url, +) + logger = logging.getLogger(__name__) @@ -622,258 +636,4 @@ def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]] ] -_PUBLIC_WEB_UA = "lambda-gurumi-bot/1.0 (+https://github.com/nalbam/lambda-gurumi-bot)" -_WEB_FETCH_TIMEOUT = 12 # Jina Reader + direct raw GET; capped shorter than Slack internal fetch (15s) because web targets have no retry budget inside the tool. - - -def _validate_public_https_url(url: str) -> tuple[str, str]: - """Return (scheme, hostname) after asserting the URL is safe to fetch. - - Rules: - - scheme == 'https' - - hostname is present and is NOT an IP literal - - every address returned by getaddrinfo for the hostname is a public, - routable unicast address (not private / loopback / link-local / - reserved / multicast / unspecified). - """ - parsed = urllib.parse.urlparse(url) - if parsed.scheme != "https": - raise ValueError("fetch_webpage requires https") - host = parsed.hostname - if not host: - raise ValueError("URL missing hostname") - try: - ipaddress.ip_address(host) - except ValueError: - pass - else: - raise ValueError("IP literals not allowed") - try: - infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) - except socket.gaierror as exc: - raise ValueError(f"DNS resolution failed: {exc}") from exc - if not infos: - raise ValueError("DNS resolution returned no addresses") - for info in infos: - addr = ipaddress.ip_address(info[4][0]) - if ( - addr.is_private - or addr.is_loopback - or addr.is_link_local - or addr.is_reserved - or addr.is_multicast - or addr.is_unspecified - or not addr.is_global - ): - raise ValueError("hostname resolves to non-public address") - return parsed.scheme, host - - -class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): - """urllib handler that refuses to follow redirects. - - Raw fetches must hit exactly the host whose DNS we pre-validated. A 3xx - pointing at a private host would silently defeat the SSRF guard. - """ - - def redirect_request(self, req, fp, code, msg, headers, newurl): - raise urllib.error.HTTPError( - req.full_url, code, "redirects not allowed", headers, fp - ) - - -_JINA_LINK_RE = re.compile( - r"(? links out of raw HTML. - - Skips content inside script/style/noscript/template. Breaks paragraphs - on block-level end tags. Link hrefs are resolved against ``base_url``; - filtering (https-only, dedup, self-ref drop) is left to ``_filter_links``. - """ - - _SKIP_TAGS = {"script", "style", "noscript", "template"} - _BREAK_TAGS = {"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br", "tr"} - - def __init__(self, base_url: str): - super().__init__(convert_charrefs=True) - self._base_url = base_url - self._skip_depth = 0 - self._text_chunks: list[str] = [] - self._title_chunks: list[str] = [] - self._in_title = False - self._current_link_url: str | None = None - self._current_link_text: list[str] = [] - self.links: list[tuple[str, str]] = [] - - def handle_starttag(self, tag, attrs): - if tag in self._SKIP_TAGS: - self._skip_depth += 1 - return - if tag == "title": - self._in_title = True - return - if tag == "a": - # Nested : the inner link overwrites the outer one. Matches - # how HTML5 parsers implicitly close an unclosed ; the outer - # link is lost but the inner (usually more specific) is kept. - href = dict(attrs).get("href") - if href: - self._current_link_url = urllib.parse.urljoin(self._base_url, href) - self._current_link_text = [] - - def handle_endtag(self, tag): - if tag in self._SKIP_TAGS: - if self._skip_depth > 0: - self._skip_depth -= 1 - return - if tag == "title": - self._in_title = False - return - if tag == "a" and self._current_link_url is not None: - text = " ".join("".join(self._current_link_text).split()) - self.links.append((text, self._current_link_url)) - self._current_link_url = None - self._current_link_text = [] - return - if tag in self._BREAK_TAGS: - self._text_chunks.append("\n") - - def handle_data(self, data): - if self._skip_depth > 0: - return - if self._in_title: - self._title_chunks.append(data) - return - if self._current_link_url is not None: - self._current_link_text.append(data) - self._text_chunks.append(data) - - def title(self) -> str: - return " ".join("".join(self._title_chunks).split()) - - def text(self) -> str: - joined = "".join(self._text_chunks) - lines = [" ".join(line.split()) for line in joined.split("\n")] - return "\n".join(line for line in lines if line) - - -def _filter_links( - raw: list[tuple[str, str]], base_url: str, limit: int -) -> list[dict[str, str]]: - if limit <= 0: - return [] - base_parsed = urllib.parse.urlparse(base_url) - base_key = base_parsed._replace( - scheme=base_parsed.scheme.lower(), - netloc=base_parsed.netloc.lower(), - fragment="", - ).geturl() - seen: set[str] = set() - out: list[dict[str, str]] = [] - for text, url in raw: - if not url.startswith("https://"): - continue - parsed = urllib.parse.urlparse(url) - if not parsed.netloc: - continue - # Normalize host case for comparison (scheme+host are case-insensitive) - normalized = parsed._replace( - scheme=parsed.scheme.lower(), - netloc=parsed.netloc.lower(), - fragment="", - ).geturl() - if normalized == base_key: - continue - if normalized in seen: - continue - seen.add(normalized) - out.append({"title": (text or url).strip(), "url": url}) - if len(out) >= limit: - break - return out - - -def _extract_markdown_links( - md: str, base_url: str, limit: int -) -> list[dict[str, str]]: - raw = [(m.group(1), m.group(2)) for m in _JINA_LINK_RE.finditer(md)] - return _filter_links(raw, base_url, limit) - - -def _read_body_capped(response, max_bytes: int) -> bytes: - content_length = response.headers.get("Content-Length") if response.headers else None - if content_length and content_length.isdigit() and int(content_length) > max_bytes: - raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") - body = response.read(max_bytes + 1) - if len(body) > max_bytes: - raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") - return body - - -def _jina_fetch(base: str, target_url: str, max_bytes: int) -> str: - """Call Jina Reader and return the markdown body as text. - - Size gate is identical to the raw path; oversize responses raise so the - caller can fall through to a direct fetch (which may be smaller). - """ - quoted = urllib.parse.quote(target_url, safe=":/?#[]@!$&'()*+,;=") - endpoint = f"{base.rstrip('/')}/{quoted}" - req = urllib.request.Request( - endpoint, - headers={ - "Accept": "text/markdown", - "User-Agent": _PUBLIC_WEB_UA, - "X-Return-Format": "markdown", - }, - ) - with urllib.request.urlopen(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL built from validated base + percent-encoded target) - body = _read_body_capped(response, max_bytes) - return body.decode("utf-8", errors="replace") - - -def _raw_fetch(url: str, max_bytes: int) -> str: - """Direct GET on the target URL with redirects disabled. - - Caller is expected to have passed ``url`` through - ``_validate_public_https_url`` so the connection target is public. - """ - opener = urllib.request.build_opener(_NoRedirectHandler()) - req = urllib.request.Request(url, headers={"User-Agent": _PUBLIC_WEB_UA}) - with opener.open(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL pre-validated by _validate_public_https_url; redirects disabled by _NoRedirectHandler) - body = _read_body_capped(response, max_bytes) - return body.decode("utf-8", errors="replace") - - -def _parse_jina_response(text: str) -> tuple[str, str]: - """Split the Jina Reader preamble ("Title:", "URL Source:", "Markdown - Content:") from the body. Returns (title, body).""" - if not text: - return "", "" - all_lines = text.split("\n") - title = "" - body_start = 0 - for i, line in enumerate(all_lines[:_JINA_HEADER_MAX_LINES]): - if line.startswith("Title: "): - title = line[len("Title: "):].strip() - body_start = max(body_start, i + 1) - elif line.startswith("URL Source: "): - body_start = max(body_start, i + 1) - elif line.startswith("Markdown Content:"): - inline = line[len("Markdown Content:"):].strip() - if inline: - # inline content on same line as the marker: keep it - body_lines = [inline] + all_lines[i + 1:] - return title, "\n".join(body_lines).lstrip("\n") - body_start = i + 1 - break - if body_start == 0: - return title, text - body = "\n".join(all_lines[body_start:]).lstrip("\n") - return title, body - diff --git a/src/tools_web.py b/src/tools_web.py new file mode 100644 index 0000000..4c20434 --- /dev/null +++ b/src/tools_web.py @@ -0,0 +1,270 @@ +"""Web-fetch helpers used by the fetch_webpage tool. + +Split out from src/tools.py to keep each module focused. Symbols defined +here are re-exported from src.tools for backward compatibility with test +imports and callers. +""" +from __future__ import annotations + +import ipaddress +import re +import socket +import urllib.error +import urllib.parse +import urllib.request +from html.parser import HTMLParser + + +_PUBLIC_WEB_UA = "lambda-gurumi-bot/1.0 (+https://github.com/nalbam/lambda-gurumi-bot)" +_WEB_FETCH_TIMEOUT = 12 # Jina Reader + direct raw GET; capped shorter than Slack internal fetch (15s) because web targets have no retry budget inside the tool. + + +def _validate_public_https_url(url: str) -> tuple[str, str]: + """Return (scheme, hostname) after asserting the URL is safe to fetch. + + Rules: + - scheme == 'https' + - hostname is present and is NOT an IP literal + - every address returned by getaddrinfo for the hostname is a public, + routable unicast address (not private / loopback / link-local / + reserved / multicast / unspecified). + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise ValueError("fetch_webpage requires https") + host = parsed.hostname + if not host: + raise ValueError("URL missing hostname") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise ValueError("IP literals not allowed") + try: + infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise ValueError(f"DNS resolution failed: {exc}") from exc + if not infos: + raise ValueError("DNS resolution returned no addresses") + for info in infos: + addr = ipaddress.ip_address(info[4][0]) + if ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_reserved + or addr.is_multicast + or addr.is_unspecified + or not addr.is_global + ): + raise ValueError("hostname resolves to non-public address") + return parsed.scheme, host + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """urllib handler that refuses to follow redirects. + + Raw fetches must hit exactly the host whose DNS we pre-validated. A 3xx + pointing at a private host would silently defeat the SSRF guard. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise urllib.error.HTTPError( + req.full_url, code, "redirects not allowed", headers, fp + ) + + +_JINA_LINK_RE = re.compile( + r"(? links out of raw HTML. + + Skips content inside script/style/noscript/template. Breaks paragraphs + on block-level end tags. Link hrefs are resolved against ``base_url``; + filtering (https-only, dedup, self-ref drop) is left to ``_filter_links``. + """ + + _SKIP_TAGS = {"script", "style", "noscript", "template"} + _BREAK_TAGS = {"p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br", "tr"} + + def __init__(self, base_url: str): + super().__init__(convert_charrefs=True) + self._base_url = base_url + self._skip_depth = 0 + self._text_chunks: list[str] = [] + self._title_chunks: list[str] = [] + self._in_title = False + self._current_link_url: str | None = None + self._current_link_text: list[str] = [] + self.links: list[tuple[str, str]] = [] + + def handle_starttag(self, tag, attrs): + if tag in self._SKIP_TAGS: + self._skip_depth += 1 + return + if tag == "title": + self._in_title = True + return + if tag == "a": + # Nested : the inner link overwrites the outer one. Matches + # how HTML5 parsers implicitly close an unclosed ; the outer + # link is lost but the inner (usually more specific) is kept. + href = dict(attrs).get("href") + if href: + self._current_link_url = urllib.parse.urljoin(self._base_url, href) + self._current_link_text = [] + + def handle_endtag(self, tag): + if tag in self._SKIP_TAGS: + if self._skip_depth > 0: + self._skip_depth -= 1 + return + if tag == "title": + self._in_title = False + return + if tag == "a" and self._current_link_url is not None: + text = " ".join("".join(self._current_link_text).split()) + self.links.append((text, self._current_link_url)) + self._current_link_url = None + self._current_link_text = [] + return + if tag in self._BREAK_TAGS: + self._text_chunks.append("\n") + + def handle_data(self, data): + if self._skip_depth > 0: + return + if self._in_title: + self._title_chunks.append(data) + return + if self._current_link_url is not None: + self._current_link_text.append(data) + self._text_chunks.append(data) + + def title(self) -> str: + return " ".join("".join(self._title_chunks).split()) + + def text(self) -> str: + joined = "".join(self._text_chunks) + lines = [" ".join(line.split()) for line in joined.split("\n")] + return "\n".join(line for line in lines if line) + + +def _filter_links( + raw: list[tuple[str, str]], base_url: str, limit: int +) -> list[dict[str, str]]: + if limit <= 0: + return [] + base_parsed = urllib.parse.urlparse(base_url) + base_key = base_parsed._replace( + scheme=base_parsed.scheme.lower(), + netloc=base_parsed.netloc.lower(), + fragment="", + ).geturl() + seen: set[str] = set() + out: list[dict[str, str]] = [] + for text, url in raw: + if not url.startswith("https://"): + continue + parsed = urllib.parse.urlparse(url) + if not parsed.netloc: + continue + # Normalize host case for comparison (scheme+host are case-insensitive) + normalized = parsed._replace( + scheme=parsed.scheme.lower(), + netloc=parsed.netloc.lower(), + fragment="", + ).geturl() + if normalized == base_key: + continue + if normalized in seen: + continue + seen.add(normalized) + out.append({"title": (text or url).strip(), "url": url}) + if len(out) >= limit: + break + return out + + +def _extract_markdown_links( + md: str, base_url: str, limit: int +) -> list[dict[str, str]]: + raw = [(m.group(1), m.group(2)) for m in _JINA_LINK_RE.finditer(md)] + return _filter_links(raw, base_url, limit) + + +def _read_body_capped(response, max_bytes: int) -> bytes: + content_length = response.headers.get("Content-Length") if response.headers else None + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") + body = response.read(max_bytes + 1) + if len(body) > max_bytes: + raise ValueError(f"webpage exceeds MAX_WEB_BYTES={max_bytes}") + return body + + +def _jina_fetch(base: str, target_url: str, max_bytes: int) -> str: + """Call Jina Reader and return the markdown body as text. + + Size gate is identical to the raw path; oversize responses raise so the + caller can fall through to a direct fetch (which may be smaller). + """ + quoted = urllib.parse.quote(target_url, safe=":/?#[]@!$&'()*+,;=") + endpoint = f"{base.rstrip('/')}/{quoted}" + req = urllib.request.Request( + endpoint, + headers={ + "Accept": "text/markdown", + "User-Agent": _PUBLIC_WEB_UA, + "X-Return-Format": "markdown", + }, + ) + with urllib.request.urlopen(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL built from validated base + percent-encoded target) + body = _read_body_capped(response, max_bytes) + return body.decode("utf-8", errors="replace") + + +def _raw_fetch(url: str, max_bytes: int) -> str: + """Direct GET on the target URL with redirects disabled. + + Caller is expected to have passed ``url`` through + ``_validate_public_https_url`` so the connection target is public. + """ + opener = urllib.request.build_opener(_NoRedirectHandler()) + req = urllib.request.Request(url, headers={"User-Agent": _PUBLIC_WEB_UA}) + with opener.open(req, timeout=_WEB_FETCH_TIMEOUT) as response: # noqa: S310 (URL pre-validated by _validate_public_https_url; redirects disabled by _NoRedirectHandler) + body = _read_body_capped(response, max_bytes) + return body.decode("utf-8", errors="replace") + + +def _parse_jina_response(text: str) -> tuple[str, str]: + """Split the Jina Reader preamble ("Title:", "URL Source:", "Markdown + Content:") from the body. Returns (title, body).""" + if not text: + return "", "" + all_lines = text.split("\n") + title = "" + body_start = 0 + for i, line in enumerate(all_lines[:_JINA_HEADER_MAX_LINES]): + if line.startswith("Title: "): + title = line[len("Title: "):].strip() + body_start = max(body_start, i + 1) + elif line.startswith("URL Source: "): + body_start = max(body_start, i + 1) + elif line.startswith("Markdown Content:"): + inline = line[len("Markdown Content:"):].strip() + if inline: + # inline content on same line as the marker: keep it + body_lines = [inline] + all_lines[i + 1:] + return title, "\n".join(body_lines).lstrip("\n") + body_start = i + 1 + break + if body_start == 0: + return title, text + body = "\n".join(all_lines[body_start:]).lstrip("\n") + return title, body diff --git a/tests/test_tools.py b/tests/test_tools.py index d821851..25983de 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -773,7 +773,7 @@ def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): # Simulate DNS pointing at RFC1918 space. return [(None, None, None, "", ("10.0.0.1", port))] - monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) with pytest.raises(ValueError, match="non-public"): _validate_public_https_url("https://internal.corp.example/") @@ -784,7 +784,7 @@ def test_validate_public_https_url_rejects_metadata_host(monkeypatch): def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("169.254.169.254", port))] - monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) with pytest.raises(ValueError, match="non-public"): _validate_public_https_url("https://cloud.metadata.example/") @@ -795,7 +795,7 @@ def test_validate_public_https_url_accepts_public_host(monkeypatch): def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("93.184.216.34", port))] # example.com - monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) scheme, host = _validate_public_https_url("https://example.com/path") assert scheme == "https" assert host == "example.com" @@ -809,7 +809,7 @@ def test_validate_public_https_url_dns_failure(monkeypatch): def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): raise _socket.gaierror("nodename nor servname provided") - monkeypatch.setattr("src.tools.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) with pytest.raises(ValueError, match="DNS resolution failed"): _validate_public_https_url("https://nonexistent.invalid.example/") @@ -1017,7 +1017,7 @@ def test_jina_fetch_returns_body_under_cap(): from src.tools import _jina_fetch payload = b"Title: x\nURL Source: https://example.com/\n\nBody here." - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools_web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": str(len(payload)), "Content-Type": "text/markdown"} resp.read.side_effect = _streamed_read(payload) @@ -1028,7 +1028,7 @@ def test_jina_fetch_returns_body_under_cap(): def test_jina_fetch_content_length_over_cap(): from src.tools import _jina_fetch - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools_web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": "999999"} resp.read.side_effect = _streamed_read(b"x" * 10) @@ -1040,7 +1040,7 @@ def test_jina_fetch_streamed_over_cap(): from src.tools import _jina_fetch body = b"x" * 2000 - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools_web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {} # no Content-Length resp.read.side_effect = _streamed_read(body) @@ -1059,7 +1059,7 @@ def test_raw_fetch_returns_body_under_cap(monkeypatch): cm = fake_opener.open.return_value.__enter__.return_value cm.headers = {"Content-Length": str(len(body)), "Content-Type": "text/html"} cm.read.side_effect = _streamed_read(body) - monkeypatch.setattr("src.tools.urllib.request.build_opener", lambda *_: fake_opener) + monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) html = _raw_fetch("https://example.com/", max_bytes=1024) assert "hello" in html @@ -1073,6 +1073,6 @@ def test_raw_fetch_streamed_over_cap(monkeypatch): cm = fake_opener.open.return_value.__enter__.return_value cm.headers = {} cm.read.side_effect = _streamed_read(body) - monkeypatch.setattr("src.tools.urllib.request.build_opener", lambda *_: fake_opener) + monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) with pytest.raises(ValueError, match="MAX_WEB_BYTES"): _raw_fetch("https://example.com/", max_bytes=1024) From cf82880274d5ad2e09ca5591bebd6552b83e5675 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 15:03:06 +0900 Subject: [PATCH 22/65] feat(tools): add fetch_webpage tool with Jina+raw fallback Register fetch_webpage in default_registry alongside other built-in tools. Tries Jina Reader first (markdown output, link extraction); falls back to direct raw GET + HTML text extraction on 5xx or oversize. Adds import socket for socket.timeout handling. Includes 7 end-to-end tests covering happy path, fallback, truncation, and link dedup. Co-Authored-By: Claude Sonnet 4.6 --- src/tools.py | 112 +++++++++++++++++++++++++++ tests/test_tools.py | 181 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) diff --git a/src/tools.py b/src/tools.py index 8eed557..0c63e25 100644 --- a/src/tools.py +++ b/src/tools.py @@ -9,6 +9,7 @@ import json import logging +import socket import time import urllib.error import urllib.parse @@ -512,6 +513,117 @@ def search_web(ctx: ToolContext, query: str, limit: int = 5) -> list[dict[str, s return _ddg_search(query, limit) +@tool( + default_registry, + name="fetch_webpage", + description=( + "Fetch a public HTTPS web page and return clean text content plus a " + "list of outbound links. Use this for summarizing articles, landing " + "pages, or news indexes. For Slack-hosted files use " + "read_attached_document / read_attached_images instead." + ), + parameters={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Absolute https URL of the page to fetch.", + }, + "max_chars": { + "type": "integer", + "minimum": 500, + "maximum": 20000, + "description": "Optional cap on returned content characters. Clamped by MAX_WEB_CHARS.", + }, + "max_links": { + "type": "integer", + "minimum": 0, + "maximum": 50, + "description": "Optional cap on returned link count. Clamped by MAX_WEB_LINKS.", + }, + }, + "required": ["url"], + }, + timeout=30.0, +) +def fetch_webpage( + ctx: ToolContext, + url: str, + max_chars: int | None = None, + max_links: int | None = None, +) -> dict[str, Any]: + _validate_public_https_url(url) + settings = ctx.settings + effective_chars = min( + max_chars if max_chars is not None else settings.max_web_chars, + settings.max_web_chars, + ) + effective_links = min( + max_links if max_links is not None else settings.max_web_links, + settings.max_web_links, + ) + + title = "" + content = "" + links: list[dict[str, str]] = [] + source = "raw" + jina_err: str | None = None + + try: + jina_text = _jina_fetch(settings.jina_reader_base, url, settings.max_web_bytes) + parsed_title, body = _parse_jina_response(jina_text) + if body.strip(): + title = parsed_title + content = body + links = _extract_markdown_links(body, url, effective_links) + source = "jina" + else: + jina_err = "empty body" + except ( + ValueError, + urllib.error.URLError, + urllib.error.HTTPError, + socket.timeout, + UnicodeDecodeError, + ) as exc: + jina_err = f"{exc.__class__.__name__}: {exc}" + logger.info("fetch_webpage jina fallback: %s", jina_err) + + if source != "jina": + _validate_public_https_url(url) + try: + html_text = _raw_fetch(url, settings.max_web_bytes) + except ( + ValueError, + urllib.error.URLError, + urllib.error.HTTPError, + socket.timeout, + ) as exc: + raise ValueError( + f"fetch_webpage failed: jina={jina_err}, raw={exc.__class__.__name__}: {exc}" + ) from exc + extractor = _HtmlTextExtractor(url) + extractor.feed(html_text) + title = extractor.title() or title + content = extractor.text() + links = _filter_links(extractor.links, url, effective_links) + + truncated = False + if len(content) > effective_chars: + content = content[:effective_chars] + truncated = True + + return { + "url": url, + "title": title, + "content": content, + "links": links, + "chars": len(content), + "truncated": truncated, + "source": source, + } + + @tool( default_registry, name="generate_image", diff --git a/tests/test_tools.py b/tests/test_tools.py index 25983de..b91af11 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1076,3 +1076,184 @@ def test_raw_fetch_streamed_over_cap(monkeypatch): monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) with pytest.raises(ValueError, match="MAX_WEB_BYTES"): _raw_fetch("https://example.com/", max_bytes=1024) + + +# --------------------------------------------------------------------------- # +# fetch_webpage — end-to-end via tool function +# --------------------------------------------------------------------------- # + + +def _public_dns(monkeypatch): + """Route all src.tools_web.socket.getaddrinfo lookups to a public IP.""" + + def _public(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("93.184.216.34", port))] + + monkeypatch.setattr("src.tools_web.socket.getaddrinfo", _public) + + +def test_default_registry_now_includes_fetch_webpage(): + names = set(default_registry.names()) + assert "fetch_webpage" in names + + +def test_fetch_webpage_rejects_http_via_tool(monkeypatch): + from src.tools import fetch_webpage + + ctx = _ctx() + with pytest.raises(ValueError, match="https"): + fetch_webpage(ctx, url="http://example.com/") + + +def test_fetch_webpage_jina_happy_path(monkeypatch): + from src.tools import fetch_webpage + + _public_dns(monkeypatch) + jina_body = ( + b"Title: Example Page\n" + b"URL Source: https://example.com/\n" + b"Markdown Content:\n" + b"# Hello\n\nSee [Docs](https://docs.example.com/) and [Blog](https://blog.example.com/).\n" + ) + ctx = _ctx() + with patch("src.tools_web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(jina_body))} + resp.read.side_effect = _streamed_read(jina_body) + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "jina" + assert out["title"] == "Example Page" + assert "Hello" in out["content"] + urls = [link["url"] for link in out["links"]] + assert "https://docs.example.com/" in urls + assert "https://blog.example.com/" in urls + assert out["truncated"] is False + assert out["chars"] == len(out["content"]) + + +def test_fetch_webpage_falls_back_to_raw_on_jina_5xx(monkeypatch): + from src.tools import fetch_webpage + import urllib.error + + _public_dns(monkeypatch) + html_body = ( + b"Raw Title" + b"

Raw body.

" + b"
Docs" + ) + + def jina_fail(*args, **kwargs): + raise urllib.error.HTTPError( + "https://r.jina.ai/https://example.com/", 500, "boom", {}, None + ) + + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(html_body))} + cm.read.side_effect = _streamed_read(html_body) + + monkeypatch.setattr("src.tools_web.urllib.request.urlopen", jina_fail) + monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) + + ctx = _ctx() + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "raw" + assert out["title"] == "Raw Title" + assert "Raw body." in out["content"] + assert any(link["url"] == "https://docs.example.com/" for link in out["links"]) + + +def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): + """Jina oversize → fall through to raw fetch instead of raising.""" + from src.tools import fetch_webpage + + _public_dns(monkeypatch) + settings = Settings( + slack_bot_token="xoxb-test", + slack_signing_secret="sig", + llm_provider="openai", + llm_model="gpt-4o-mini", + image_provider="openai", + image_model="gpt-image-1", + agent_max_steps=3, + response_language="ko", + dynamodb_table_name="t", + aws_region="us-east-1", + max_web_bytes=128, + max_web_chars=8000, + max_web_links=20, + jina_reader_base="https://r.jina.ai", + ) + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=settings, + llm=MagicMock(), + ) + + huge_jina = b"x" * 4096 + raw_html = b"

raw small body

" + + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(raw_html))} + cm.read.side_effect = _streamed_read(raw_html) + + with patch("src.tools_web.urllib.request.urlopen") as jina_open: + jresp = jina_open.return_value.__enter__.return_value + jresp.headers = {} # no Content-Length → streamed-read path + jresp.read.side_effect = _streamed_read(huge_jina) + monkeypatch_build = patch("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) + with monkeypatch_build: + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "raw" + assert "raw small body" in out["content"] + + +def test_fetch_webpage_max_chars_truncates(monkeypatch): + from src.tools import fetch_webpage + + _public_dns(monkeypatch) + long_body = b"Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + (b"A" * 500) + ctx = _ctx() + with patch("src.tools_web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(long_body))} + resp.read.side_effect = _streamed_read(long_body) + out = fetch_webpage(ctx, url="https://example.com/", max_chars=200) + assert out["truncated"] is True + assert out["chars"] == 200 + assert len(out["content"]) == 200 + + +def test_fetch_webpage_max_links_dedup(monkeypatch): + from src.tools import fetch_webpage + + _public_dns(monkeypatch) + link_section = ( + "[a](https://a.example/)" + "[b](https://b.example/)" + "[c](https://c.example/)" + "[d](https://d.example/)" + "[dup-a](https://a.example/)" + "[e](https://e.example/)" + "[dup-b](https://b.example/)" + "[f](https://f.example/)" + ) + payload = ( + "Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + link_section + ).encode() + ctx = _ctx() + with patch("src.tools_web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(payload))} + resp.read.side_effect = _streamed_read(payload) + out = fetch_webpage(ctx, url="https://example.com/", max_links=3) + urls = [link["url"] for link in out["links"]] + assert urls == [ + "https://a.example/", + "https://b.example/", + "https://c.example/", + ] From 6dc01ccdc72b9abb5ced8f69a18ffadc3f57780e Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 15:08:09 +0900 Subject: [PATCH 23/65] refactor(tools): clarify fetch_webpage fallback flow and demote jina-fallback log --- src/tools.py | 14 ++++++++++---- tests/test_tools.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/tools.py b/src/tools.py index 0c63e25..1082e00 100644 --- a/src/tools.py +++ b/src/tools.py @@ -566,7 +566,7 @@ def fetch_webpage( title = "" content = "" links: list[dict[str, str]] = [] - source = "raw" + jina_ok = False jina_err: str | None = None try: @@ -576,7 +576,7 @@ def fetch_webpage( title = parsed_title content = body links = _extract_markdown_links(body, url, effective_links) - source = "jina" + jina_ok = True else: jina_err = "empty body" except ( @@ -587,9 +587,13 @@ def fetch_webpage( UnicodeDecodeError, ) as exc: jina_err = f"{exc.__class__.__name__}: {exc}" - logger.info("fetch_webpage jina fallback: %s", jina_err) + logger.debug("fetch_webpage jina fallback (url=%s): %s", url, jina_err) - if source != "jina": + if not jina_ok: + # Belt-and-suspenders: re-validate before the raw fetch. _raw_fetch + # assumes its caller has passed the URL through + # _validate_public_https_url; re-checking here keeps that contract + # enforced even if future refactors mutate `url` in between. _validate_public_https_url(url) try: html_text = _raw_fetch(url, settings.max_web_bytes) @@ -608,6 +612,8 @@ def fetch_webpage( content = extractor.text() links = _filter_links(extractor.links, url, effective_links) + source = "jina" if jina_ok else "raw" + truncated = False if len(content) > effective_chars: content = content[:effective_chars] diff --git a/tests/test_tools.py b/tests/test_tools.py index b91af11..a28854d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1228,6 +1228,35 @@ def test_fetch_webpage_max_chars_truncates(monkeypatch): assert len(out["content"]) == 200 +def test_fetch_webpage_both_paths_fail_raises(monkeypatch): + from src.tools import fetch_webpage + import urllib.error + + _public_dns(monkeypatch) + + def jina_500(*args, **kwargs): + raise urllib.error.HTTPError( + "https://r.jina.ai/...", 500, "jina boom", {}, None + ) + + def raw_503(*args, **kwargs): + raise urllib.error.HTTPError( + "https://example.com/", 503, "raw boom", {}, None + ) + + fake_opener = MagicMock() + fake_opener.open.side_effect = raw_503 + + monkeypatch.setattr("src.tools_web.urllib.request.urlopen", jina_500) + monkeypatch.setattr( + "src.tools_web.urllib.request.build_opener", lambda *_: fake_opener + ) + + ctx = _ctx() + with pytest.raises(ValueError, match=r"jina=.*raw="): + fetch_webpage(ctx, url="https://example.com/") + + def test_fetch_webpage_max_links_dedup(monkeypatch): from src.tools import fetch_webpage From b1207fa95ea8894cf2484e510373b0d871f1c278 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 15:09:31 +0900 Subject: [PATCH 24/65] docs: document fetch_webpage env vars and SSRF gating --- .env.example | 6 ++++++ CLAUDE.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 52110f0..34aeffd 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,9 @@ DEFAULT_TIMEZONE="Asia/Seoul" # IANA TZ for get_current_time default MAX_DOC_CHARS=20000 # per-document extracted-text cap (>=1000) MAX_DOC_PAGES=50 # per-document PDF page cap (>=1) MAX_DOC_BYTES=26214400 # per-document download cap, 25MB (>=65536) + +# --- Web fetch (fetch_webpage tool) --- +MAX_WEB_CHARS=8000 # per-fetch returned content char cap (>=500) +MAX_WEB_BYTES=2097152 # per-fetch download cap, 2MB (>=65536) +MAX_WEB_LINKS=20 # per-fetch outbound-link cap (>=0) +JINA_READER_BASE="https://r.jina.ai" # override for self-hosted Jina Reader diff --git a/CLAUDE.md b/CLAUDE.md index 4aff7d9..f0a2960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,10 @@ Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; `MessageFormatter.split_message` (in `src/slack_helpers.py`) splits on `\`\`\`` first (so complete code blocks survive), then on `\n\n`, then on `.!?` sentence boundaries, then hard slice. `_merge_small` rejoins adjacent small chunks up to `max_len`. First chunk goes via `chat_update` on the placeholder message; the rest via `chat_postMessage(thread_ts=…)`. If `chat_update` fails (`msg_too_long` etc.), that chunk falls back to a new message. +### Public web fetching is SSRF-gated + +`fetch_webpage` uses `_validate_public_https_url` (in `src/tools_web.py`) to enforce `https`, reject IP literals, and drop DNS results resolving to any non-public address (private / loopback / link-local / reserved / multicast / unspecified / non-global — CGNAT `100.64.0.0/10` included). The Jina Reader path (`{JINA_READER_BASE}/{percent-encoded url}`) does the actual network hop against the target; the raw fallback goes direct with a `_NoRedirectHandler` that refuses 3xx, so a redirect into RFC1918 space can't slip past the pre-flight DNS check. Body size is capped by `MAX_WEB_BYTES` on both paths; if Jina exceeds the cap we fall through to raw (the direct fetch may be smaller than Jina's markdown-ified output). Web helpers live in `src/tools_web.py` and are re-exported from `src/tools.py` for callers and test imports. + ### Config is lazy, not import-time `Settings.from_env()` runs at module load but does NOT validate Slack credentials. `Settings.require_slack_credentials()` is called from `_get_bolt_app()` so the first request fails cleanly, but tests and tooling can import `app` without `SLACK_BOT_TOKEN`. @@ -161,6 +165,8 @@ Per-module coverage: `agent.py` 96%, `config.py` 98%, `tools.py` 90%, `llm.py` 8 - **Switching to `LoggerAdapter.info(extra=…)`** — in Python 3.12 the adapter's `process()` overwrites `extra`; keep going through `logger.logger` for `extra_fields`. - **Removing the SSRF host allowlist** (`SLACK_FILE_HOSTS`) shared by `read_attached_images` and `read_attached_document` (`_fetch_slack_file`) opens up arbitrary URL fetch with the bot token. - **Adding a tool without updating `ToolRegistry.specs()`** — the `@tool` decorator handles both dispatch and LLM schema from a single declaration; inline dict tricks will silently desync. +- **Removing the SSRF guard (`_validate_public_https_url`) on `fetch_webpage`** re-opens fetch to RFC1918 space and cloud-metadata endpoints (e.g. `169.254.169.254`). +- **Enabling redirects on the `fetch_webpage` raw fallback** — a 302 to a private host bypasses the pre-flight DNS check; keep `_NoRedirectHandler` installed. ## Excluded (Phase 2+) From 76410e10c9be5cf78414cd2fdcbe1a66f22ad88f Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 15:19:44 +0900 Subject: [PATCH 25/65] fix(tools): document DNS rebinding, clarify except order, validate JINA_READER_BASE scheme --- CLAUDE.md | 3 ++- src/config.py | 15 +++++++++++++-- src/tools.py | 7 +++++-- tests/test_config.py | 7 +++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0a2960..6cbfd72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; ### Public web fetching is SSRF-gated -`fetch_webpage` uses `_validate_public_https_url` (in `src/tools_web.py`) to enforce `https`, reject IP literals, and drop DNS results resolving to any non-public address (private / loopback / link-local / reserved / multicast / unspecified / non-global — CGNAT `100.64.0.0/10` included). The Jina Reader path (`{JINA_READER_BASE}/{percent-encoded url}`) does the actual network hop against the target; the raw fallback goes direct with a `_NoRedirectHandler` that refuses 3xx, so a redirect into RFC1918 space can't slip past the pre-flight DNS check. Body size is capped by `MAX_WEB_BYTES` on both paths; if Jina exceeds the cap we fall through to raw (the direct fetch may be smaller than Jina's markdown-ified output). Web helpers live in `src/tools_web.py` and are re-exported from `src/tools.py` for callers and test imports. +`fetch_webpage` uses `_validate_public_https_url` (in `src/tools_web.py`) to enforce `https`, reject IP literals, and drop DNS results resolving to any non-public address (private / loopback / link-local / reserved / multicast / unspecified / non-global — CGNAT `100.64.0.0/10` included). The Jina Reader path (`{JINA_READER_BASE}/{percent-encoded url}`) does the actual network hop against the target; the raw fallback (and only the raw fallback, since the Jina path uses Jina's own fetch) goes direct with a `_NoRedirectHandler` that refuses 3xx, so a redirect into RFC1918 space can't slip past the pre-flight DNS check. Body size is capped by `MAX_WEB_BYTES` on both paths; if Jina exceeds the cap we fall through to raw (the direct fetch may be smaller than Jina's markdown-ified output). Web helpers live in `src/tools_web.py` and are re-exported from `src/tools.py` for callers and test imports. ### Config is lazy, not import-time @@ -167,6 +167,7 @@ Per-module coverage: `agent.py` 96%, `config.py` 98%, `tools.py` 90%, `llm.py` 8 - **Adding a tool without updating `ToolRegistry.specs()`** — the `@tool` decorator handles both dispatch and LLM schema from a single declaration; inline dict tricks will silently desync. - **Removing the SSRF guard (`_validate_public_https_url`) on `fetch_webpage`** re-opens fetch to RFC1918 space and cloud-metadata endpoints (e.g. `169.254.169.254`). - **Enabling redirects on the `fetch_webpage` raw fallback** — a 302 to a private host bypasses the pre-flight DNS check; keep `_NoRedirectHandler` installed. +- **DNS rebinding on `fetch_webpage` raw fallback**. The pre-flight `getaddrinfo` check and the eventual TCP connect are two separate DNS lookups; a TTL=0 attacker can flip between them. Lambda's environment makes the attack hard and impact is bounded (no VPC by default), but don't treat `_validate_public_https_url` as a guarantee that the actual connection hits the same IP. If you ever add VPC/private-subnet egress, revisit this. ## Excluded (Phase 2+) diff --git a/src/config.py b/src/config.py index 7f1d93a..2f96eb4 100644 --- a/src/config.py +++ b/src/config.py @@ -70,6 +70,18 @@ def _enum_env(name: str, default: str, valid: set[str]) -> str: return value +def _https_url_env(name: str, default: str) -> str: + """Return an https:// URL from env, falling back to default on empty + or non-https values.""" + raw = os.getenv(name, "").strip() + if not raw: + return default + if not raw.startswith("https://"): + logger.warning("invalid %s=%r (not https), falling back to %s", name, raw, default) + return default + return raw + + @dataclass(frozen=True) class Settings: slack_bot_token: str @@ -143,8 +155,7 @@ def from_env(cls) -> "Settings": max_web_chars=_int_env("MAX_WEB_CHARS", 8000, minimum=500), max_web_bytes=_int_env("MAX_WEB_BYTES", 2 * 1024 * 1024, minimum=64 * 1024), max_web_links=_int_env("MAX_WEB_LINKS", 20, minimum=0), - jina_reader_base=(os.getenv("JINA_READER_BASE", "https://r.jina.ai").strip() - or "https://r.jina.ai"), + jina_reader_base=_https_url_env("JINA_READER_BASE", "https://r.jina.ai"), ) def require_slack_credentials(self) -> None: diff --git a/src/tools.py b/src/tools.py index 1082e00..8e3415d 100644 --- a/src/tools.py +++ b/src/tools.py @@ -569,6 +569,9 @@ def fetch_webpage( jina_ok = False jina_err: str | None = None + # HTTPError is a subclass of URLError; we list both to make the + # "HTTP status error" path visible as a first-class error type for + # readers. Order doesn't affect matching (URLError catches both). try: jina_text = _jina_fetch(settings.jina_reader_base, url, settings.max_web_bytes) parsed_title, body = _parse_jina_response(jina_text) @@ -581,8 +584,8 @@ def fetch_webpage( jina_err = "empty body" except ( ValueError, - urllib.error.URLError, urllib.error.HTTPError, + urllib.error.URLError, socket.timeout, UnicodeDecodeError, ) as exc: @@ -599,8 +602,8 @@ def fetch_webpage( html_text = _raw_fetch(url, settings.max_web_bytes) except ( ValueError, - urllib.error.URLError, urllib.error.HTTPError, + urllib.error.URLError, socket.timeout, ) as exc: raise ValueError( diff --git a/tests/test_config.py b/tests/test_config.py index 8008fc7..a8582c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -176,3 +176,10 @@ def test_web_fetch_below_minimum_clamped(monkeypatch, reload_config): s = reload_config() assert s.max_web_chars == 500 assert s.max_web_bytes == 64 * 1024 + + +def test_web_fetch_jina_reader_base_http_rejected(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("JINA_READER_BASE", "http://internal.proxy.example") + s = reload_config() + assert s.jina_reader_base == "https://r.jina.ai" # fallback From 3d1f22088e7e6bc1210f24e491d5dab81091393c Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:24:35 +0900 Subject: [PATCH 26/65] refactor(llms): split src/llm.py into src/llms package Move 818-line src/llm.py into a 7-file src/llms/ package so each responsibility lives in its own module. Public API names are preserved via src/llms/__init__.py. All callers updated; 161 tests still pass. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 2 +- localtest.py | 2 +- src/agent.py | 2 +- src/llm.py | 818 ---------------------------------------- src/llms/__init__.py | 16 + src/llms/base.py | 80 ++++ src/llms/bedrock.py | 363 ++++++++++++++++++ src/llms/composite.py | 26 ++ src/llms/factory.py | 38 ++ src/llms/openai.py | 17 + src/llms/openai_wire.py | 293 ++++++++++++++ src/llms/xai.py | 33 ++ src/tools.py | 2 +- tests/test_agent.py | 2 +- tests/test_llm.py | 9 +- tests/test_tools.py | 2 +- 16 files changed, 878 insertions(+), 827 deletions(-) delete mode 100644 src/llm.py create mode 100644 src/llms/__init__.py create mode 100644 src/llms/base.py create mode 100644 src/llms/bedrock.py create mode 100644 src/llms/composite.py create mode 100644 src/llms/factory.py create mode 100644 src/llms/openai.py create mode 100644 src/llms/openai_wire.py create mode 100644 src/llms/xai.py diff --git a/app.py b/app.py index 4a3866b..d221319 100644 --- a/app.py +++ b/app.py @@ -22,7 +22,7 @@ from src.agent import SlackMentionAgent from src.config import Settings from src.dedup import ConversationStore, DedupStore -from src.llm import get_llm +from src.llms import get_llm from src.logging_utils import get_logger, log_event, set_request_id from src.slack_helpers import ( MessageFormatter, diff --git a/localtest.py b/localtest.py index ec8b09e..b205032 100644 --- a/localtest.py +++ b/localtest.py @@ -99,7 +99,7 @@ def main() -> None: from src.agent import SlackMentionAgent from src.config import Settings - from src.llm import get_llm + from src.llms import get_llm from src.tools import ToolContext, default_registry settings = Settings.from_env() diff --git a/src/agent.py b/src/agent.py index 97d9786..a830bff 100644 --- a/src/agent.py +++ b/src/agent.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from typing import Any, Callable -from src.llm import LLMProvider, ToolCall +from src.llms import LLMProvider, ToolCall from src.logging_utils import log_event from src.tools import ToolContext, ToolExecutor, ToolRegistry diff --git a/src/llm.py b/src/llm.py deleted file mode 100644 index a0a74dd..0000000 --- a/src/llm.py +++ /dev/null @@ -1,818 +0,0 @@ -"""LLM provider abstraction with native function calling. - -Three providers: -- OpenAIProvider: OpenAI chat completions with `tools=`, vision, image generation. -- XAIProvider: xAI (Grok) — OpenAI-wire compatible at https://api.x.ai/v1. - Shares `_OpenAICompatProvider` machinery with OpenAI; differs in image kwargs - (omits `size`, forces `response_format=b64_json`) and token params - (always `max_tokens` + `temperature`). -- BedrockProvider: family-routed. Anthropic Claude uses Messages API with tools; - Amazon Nova uses Converse API with toolConfig; others fall back to plain text. - Accepts both bare model IDs and `us./eu./apac./global.` inference-profile IDs. - -All providers implement the LLMProvider protocol so the Agent loop is -provider-agnostic. `_CompositeProvider` wraps two providers when text and -image providers differ (e.g., OpenAI text + xAI image). -""" -from __future__ import annotations - -import base64 -import json -import logging -import time -import uuid -from dataclasses import dataclass, field -from typing import Any, Callable, Literal, Protocol - -import boto3 - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- # -# Types -# --------------------------------------------------------------------------- # - -ToolSpec = dict[str, Any] # {"name","description","parameters"(JSON Schema)} - - -@dataclass -class ToolCall: - id: str - name: str - arguments: dict[str, Any] - - -@dataclass -class LLMResult: - content: str - tool_calls: list[ToolCall] = field(default_factory=list) - stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] = "end_turn" - token_usage: dict[str, int] = field(default_factory=dict) - - -class LLMProvider(Protocol): - def chat( - self, - system: str, - messages: list[dict[str, Any]], - tools: list[ToolSpec] | None = None, - max_tokens: int = 1024, - on_delta: Callable[[str], None] | None = None, - ) -> LLMResult: ... - - def stream_chat( - self, - system: str, - messages: list[dict[str, Any]], - on_delta: Callable[[str], None], - max_tokens: int = 1024, - ) -> str: ... - - def describe_image(self, image_bytes: bytes, mime_type: str) -> str: ... - - def generate_image(self, prompt: str) -> bytes: ... - - -# --------------------------------------------------------------------------- # -# Retry helper -# --------------------------------------------------------------------------- # - -_RETRYABLE_BEDROCK = {"ThrottlingException", "ServiceQuotaExceededException", "ModelTimeoutException"} - - -def _with_retry(fn: Callable[[], Any], label: str, attempts: int = 3) -> Any: - delay = 1.0 - last_exc: Exception | None = None - for attempt in range(attempts): - try: - return fn() - except Exception as exc: # noqa: BLE001 - last_exc = exc - code = getattr(getattr(exc, "response", None), "get", lambda _k, _d=None: None)("Error", {}).get("Code") if hasattr(exc, "response") else None - if code in _RETRYABLE_BEDROCK and attempt < attempts - 1: - logger.warning("%s retryable (%s), backoff %.1fs", label, code, delay) - time.sleep(delay) - delay *= 2 - continue - raise - if last_exc: - raise last_exc - - -# --------------------------------------------------------------------------- # -# OpenAI -# --------------------------------------------------------------------------- # - - -_OPENAI_NEW_GENERATION_PREFIXES = ("gpt-5", "o1", "o3", "o4") - - -def _is_new_gen_openai(model: str) -> bool: - """Newer OpenAI models (gpt-5, o1/o3/o4 reasoning) use `max_completion_tokens` - and disallow `temperature` overrides.""" - return any(model.startswith(p) for p in _OPENAI_NEW_GENERATION_PREFIXES) - - -# --------------------------------------------------------------------------- # -# Module-level helpers shared between OpenAI-compatible providers (OpenAI, xAI) -# --------------------------------------------------------------------------- # - - -def _to_openai_wire_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Translate canonical messages (our agent's shape) to OpenAI's wire shape.""" - out: list[dict[str, Any]] = [] - for msg in messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - out.append( - { - "role": "assistant", - "content": msg.get("content") or None, - "tool_calls": [ - { - "id": tc["id"], - "type": "function", - "function": { - "name": tc["name"], - "arguments": json.dumps(tc.get("arguments") or {}, ensure_ascii=False), - }, - } - for tc in msg["tool_calls"] - ], - } - ) - else: - out.append(msg) - return out - - -def _build_openai_tools_payload(tools: list[ToolSpec]) -> list[dict[str, Any]]: - return [ - { - "type": "function", - "function": { - "name": t["name"], - "description": t["description"], - "parameters": t["parameters"], - }, - } - for t in tools - ] - - -def _map_openai_finish_reason(finish: str | None) -> Literal["end_turn", "tool_use", "max_tokens", "other"]: - if finish == "tool_calls": - return "tool_use" - if finish == "length": - return "max_tokens" - if finish in {"stop", None}: - return "end_turn" - return "other" - - -def _extract_openai_usage(usage_obj) -> dict[str, int]: - if not usage_obj: - return {} - return { - "input": getattr(usage_obj, "prompt_tokens", 0) or 0, - "output": getattr(usage_obj, "completion_tokens", 0) or 0, - } - - -def _parse_openai_completion(completion) -> LLMResult: - choice = completion.choices[0] - msg = choice.message - tool_calls: list[ToolCall] = [] - for call in (msg.tool_calls or []): - try: - args = json.loads(call.function.arguments or "{}") - except json.JSONDecodeError: - args = {} - tool_calls.append(ToolCall(id=call.id, name=call.function.name, arguments=args)) - - return LLMResult( - content=msg.content or "", - tool_calls=tool_calls, - stop_reason=_map_openai_finish_reason(choice.finish_reason), - token_usage=_extract_openai_usage(getattr(completion, "usage", None)), - ) - - -def _consume_openai_stream(stream, on_delta: Callable[[str], None]) -> LLMResult: - """Drain an OpenAI-compatible chat completion stream. - - Stops forwarding content to `on_delta` once a tool_calls delta arrives — - any trailing commentary would otherwise leak into the final user reply. - tool_calls chunks are accumulated by index and returned as ToolCall list. - """ - content_parts: list[str] = [] - tool_calls_accum: dict[int, dict[str, Any]] = {} - saw_tool_calls = False - finish_reason: str | None = None - usage_obj = None - - for chunk in stream: - usage_obj = getattr(chunk, "usage", None) or usage_obj - if not chunk.choices: - continue - choice = chunk.choices[0] - delta = choice.delta - if getattr(delta, "tool_calls", None): - saw_tool_calls = True - for tc in delta.tool_calls: - idx = tc.index - slot = tool_calls_accum.setdefault(idx, {"id": None, "name": "", "arguments": ""}) - if getattr(tc, "id", None): - slot["id"] = tc.id - fn = getattr(tc, "function", None) - if fn is not None: - if getattr(fn, "name", None): - slot["name"] += fn.name - if getattr(fn, "arguments", None): - slot["arguments"] += fn.arguments - if getattr(delta, "content", None): - content_parts.append(delta.content) - if not saw_tool_calls: - on_delta(delta.content) - if getattr(choice, "finish_reason", None): - finish_reason = choice.finish_reason - - tool_calls: list[ToolCall] = [] - for idx in sorted(tool_calls_accum): - slot = tool_calls_accum[idx] - try: - args = json.loads(slot["arguments"] or "{}") - except json.JSONDecodeError: - args = {} - tool_calls.append(ToolCall(id=slot["id"] or "", name=slot["name"], arguments=args)) - - return LLMResult( - content="".join(content_parts), - tool_calls=tool_calls, - stop_reason=_map_openai_finish_reason(finish_reason), - token_usage=_extract_openai_usage(usage_obj), - ) - - -class _OpenAICompatProvider: - """Shared machinery for any OpenAI-wire-compatible chat/vision/image API. - - Subclasses set BASE_URL / API_KEY_ENV_VAR and override small hooks - (`_token_params`, `_image_generate_kwargs`). The heavy lifting — - payload assembly, streaming, tool_calls parsing — lives on this base - and on the module-level helpers above. - """ - - BASE_URL: str | None = None # None = OpenAI default - API_KEY_ENV_VAR: str = "OPENAI_API_KEY" - - def __init__(self, model: str, image_model: str, api_key: str | None = None): - self.model = model - self.image_model = image_model - self._api_key = api_key - self._client = None - - def _get_client(self): - if self._client is None: - from openai import OpenAI - - kwargs: dict[str, Any] = {} - if self.BASE_URL: - kwargs["base_url"] = self.BASE_URL - if self._api_key: - kwargs["api_key"] = self._api_key - self._client = OpenAI(**kwargs) - return self._client - - # -- hooks -------------------------------------------------------------- # - - def _token_params(self, max_tokens: int) -> dict[str, Any]: - """Default: OpenAI legacy models use max_tokens+temperature.""" - return {"max_tokens": max_tokens, "temperature": 0.2} - - def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: - """Default OpenAI (dall-e / gpt-image-1) image call kwargs.""" - kwargs: dict[str, Any] = { - "model": self.image_model, - "prompt": prompt, - "size": "1024x1024", - } - # gpt-image-1 rejects `response_format` (b64 is the default); only legacy - # DALL-E models need the explicit flag. - if self.image_model.startswith("dall-e"): - kwargs["response_format"] = "b64_json" - return kwargs - - # -- LLMProvider surface ----------------------------------------------- # - - def chat( - self, - system: str, - messages: list[dict[str, Any]], - tools: list[ToolSpec] | None = None, - max_tokens: int = 1024, - on_delta: Callable[[str], None] | None = None, - ) -> LLMResult: - client = self._get_client() - payload: dict[str, Any] = { - "model": self.model, - "messages": [{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], - **self._token_params(max_tokens), - } - if tools: - payload["tools"] = _build_openai_tools_payload(tools) - payload["tool_choice"] = "auto" - - if on_delta is None: - completion = client.chat.completions.create(**payload) - return _parse_openai_completion(completion) - - payload = {**payload, "stream": True, "stream_options": {"include_usage": True}} - stream = client.chat.completions.create(**payload) - return _consume_openai_stream(stream, on_delta) - - def stream_chat( - self, - system: str, - messages: list[dict[str, Any]], - on_delta: Callable[[str], None], - max_tokens: int = 1024, - ) -> str: - client = self._get_client() - stream = client.chat.completions.create( - model=self.model, - messages=[{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], - stream=True, - **self._token_params(max_tokens), - ) - full = "" - for chunk in stream: - delta = chunk.choices[0].delta.content if chunk.choices else None - if delta: - full += delta - on_delta(delta) - return full - - def describe_image(self, image_bytes: bytes, mime_type: str) -> str: - client = self._get_client() - encoded = base64.b64encode(image_bytes).decode("utf-8") - completion = client.chat.completions.create( - model=self.model, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": "Describe this image for a Slack conversation."}, - {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{encoded}"}}, - ], - } - ], - ) - return completion.choices[0].message.content or "" - - def generate_image(self, prompt: str) -> bytes: - client = self._get_client() - response = client.images.generate(**self._image_generate_kwargs(prompt)) - return base64.b64decode(response.data[0].b64_json) - - -class OpenAIProvider(_OpenAICompatProvider): - BASE_URL = None # default OpenAI endpoint - API_KEY_ENV_VAR = "OPENAI_API_KEY" - - def _token_params(self, max_tokens: int) -> dict[str, Any]: - # Newer OpenAI reasoning models only accept max_completion_tokens and - # reject `temperature`. Legacy chat models still use max_tokens. - if _is_new_gen_openai(self.model): - return {"max_completion_tokens": max_tokens} - return {"max_tokens": max_tokens, "temperature": 0.2} - - -class XAIProvider(_OpenAICompatProvider): - """xAI (Grok) — OpenAI-wire compatible, different base URL and image params. - - Models: - text: grok-4-1-fast-reasoning, grok-4.20-0309-reasoning, ... - image: grok-imagine-image, grok-imagine-image-pro - - Differences from OpenAI that matter here: - - `images.generate` rejects `size` (uses `aspect_ratio`/`resolution`). - We omit `size` and request `response_format=b64_json` so we can - decode bytes locally, matching the rest of the pipeline. - - All current grok chat models accept `max_tokens` + `temperature` - the classic way — no `max_completion_tokens` split. - """ - - BASE_URL = "https://api.x.ai/v1" - API_KEY_ENV_VAR = "XAI_API_KEY" - - def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: - return { - "model": self.image_model, - "prompt": prompt, - "n": 1, - "response_format": "b64_json", - } - - -# --------------------------------------------------------------------------- # -# Bedrock -# --------------------------------------------------------------------------- # - - -_INFERENCE_PROFILE_PREFIXES = ("us.", "eu.", "apac.", "global.") - - -def _strip_inference_profile_prefix(model_id: str) -> str: - """Return the bare family id from a Bedrock model or inference-profile id. - - Inference profile IDs prefix the family with a region routing hint, e.g. - `us.anthropic.claude-haiku-4-5-20251001-v1:0`. For family-level routing - ("is this a Claude? a Nova? Titan?") we care about the bare portion. - """ - for p in _INFERENCE_PROFILE_PREFIXES: - if model_id.startswith(p): - return model_id[len(p):] - return model_id - - -class BedrockProvider: - def __init__(self, model: str, image_model: str, region: str): - self.model = model - self.image_model = image_model - self.region = region - self._client = None - - def _get_client(self): - if self._client is None: - self._client = boto3.client("bedrock-runtime", region_name=self.region) - return self._client - - @property - def _text_family(self) -> str: - return _strip_inference_profile_prefix(self.model) - - @property - def _image_family(self) -> str: - return _strip_inference_profile_prefix(self.image_model) - - # -- text / tool use ---------------------------------------------------- # - - def chat( - self, - system: str, - messages: list[dict[str, Any]], - tools: list[ToolSpec] | None = None, - max_tokens: int = 1024, - on_delta: Callable[[str], None] | None = None, - ) -> LLMResult: - # Bedrock tool_use streaming is not yet implemented in this provider; - # accept the on_delta parameter for API compatibility but use the - # blocking path, then emit the final content as a single delta so - # callers still receive *something* through the streaming channel. - family = self._text_family - if family.startswith("anthropic.claude"): - result = self._claude_chat(system, messages, tools, max_tokens) - elif family.startswith("amazon.nova"): - result = self._nova_chat(system, messages, tools, max_tokens) - else: - result = self._claude_chat(system, messages, None, max_tokens) - if on_delta is not None and result.content and not result.tool_calls: - on_delta(result.content) - return result - - def _claude_chat( - self, - system: str, - messages: list[dict[str, Any]], - tools: list[ToolSpec] | None, - max_tokens: int, - ) -> LLMResult: - body: dict[str, Any] = { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": max_tokens, - "system": system, - "messages": self._to_anthropic_messages(messages), - } - if tools: - body["tools"] = [ - {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} - for t in tools - ] - - client = self._get_client() - response = _with_retry( - lambda: client.invoke_model(modelId=self.model, body=json.dumps(body)), - label="bedrock.invoke_model", - ) - payload = json.loads(response["body"].read()) - - content_text_parts: list[str] = [] - tool_calls: list[ToolCall] = [] - for block in payload.get("content", []): - if block.get("type") == "text": - content_text_parts.append(block.get("text", "")) - elif block.get("type") == "tool_use": - tool_calls.append( - ToolCall( - id=block.get("id", str(uuid.uuid4())), - name=block.get("name", ""), - arguments=block.get("input", {}) or {}, - ) - ) - - stop_reason_raw = payload.get("stop_reason", "end_turn") - stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] - if stop_reason_raw == "tool_use": - stop_reason = "tool_use" - elif stop_reason_raw == "max_tokens": - stop_reason = "max_tokens" - elif stop_reason_raw == "end_turn": - stop_reason = "end_turn" - else: - stop_reason = "other" - - usage = payload.get("usage") or {} - return LLMResult( - content="".join(content_text_parts), - tool_calls=tool_calls, - stop_reason=stop_reason, - token_usage={"input": usage.get("input_tokens", 0) or 0, "output": usage.get("output_tokens", 0) or 0}, - ) - - def _nova_chat( - self, - system: str, - messages: list[dict[str, Any]], - tools: list[ToolSpec] | None, - max_tokens: int, - ) -> LLMResult: - client = self._get_client() - payload: dict[str, Any] = { - "modelId": self.model, - "system": [{"text": system}], - "messages": self._to_nova_messages(messages), - "inferenceConfig": {"maxTokens": max_tokens, "temperature": 0.2}, - } - if tools: - payload["toolConfig"] = { - "tools": [ - {"toolSpec": {"name": t["name"], "description": t["description"], "inputSchema": {"json": t["parameters"]}}} - for t in tools - ] - } - - response = _with_retry(lambda: client.converse(**payload), label="bedrock.converse") - out_msg = response.get("output", {}).get("message", {}) - content_text_parts: list[str] = [] - tool_calls: list[ToolCall] = [] - for block in out_msg.get("content", []): - if "text" in block: - content_text_parts.append(block["text"]) - elif "toolUse" in block: - tu = block["toolUse"] - tool_calls.append( - ToolCall(id=tu.get("toolUseId") or str(uuid.uuid4()), name=tu.get("name", ""), arguments=tu.get("input", {}) or {}) - ) - - stop_reason_raw = response.get("stopReason", "end_turn") - stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] - if stop_reason_raw == "tool_use": - stop_reason = "tool_use" - elif stop_reason_raw == "max_tokens": - stop_reason = "max_tokens" - elif stop_reason_raw == "end_turn": - stop_reason = "end_turn" - else: - stop_reason = "other" - - usage = response.get("usage") or {} - return LLMResult( - content="".join(content_text_parts), - tool_calls=tool_calls, - stop_reason=stop_reason, - token_usage={"input": usage.get("inputTokens", 0) or 0, "output": usage.get("outputTokens", 0) or 0}, - ) - - # -- streaming --------------------------------------------------------- # - - def stream_chat( - self, - system: str, - messages: list[dict[str, Any]], - on_delta: Callable[[str], None], - max_tokens: int = 1024, - ) -> str: - # Bedrock streaming implementation: Claude Messages stream or Converse stream. - client = self._get_client() - full = "" - family = self._text_family - if family.startswith("anthropic.claude"): - body = { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": max_tokens, - "system": system, - "messages": self._to_anthropic_messages(messages), - } - response = client.invoke_model_with_response_stream(modelId=self.model, body=json.dumps(body)) - for event in response.get("body", []): - chunk = event.get("chunk", {}) - if not chunk: - continue - payload = json.loads(chunk.get("bytes", b"{}")) - if payload.get("type") == "content_block_delta": - delta = (payload.get("delta") or {}).get("text") or "" - if delta: - full += delta - on_delta(delta) - return full - - # Nova Converse stream - response = client.converse_stream( - modelId=self.model, - system=[{"text": system}], - messages=self._to_nova_messages(messages), - inferenceConfig={"maxTokens": max_tokens, "temperature": 0.2}, - ) - for event in response.get("stream", []): - cbd = event.get("contentBlockDelta") - if cbd: - delta = (cbd.get("delta") or {}).get("text") or "" - if delta: - full += delta - on_delta(delta) - return full - - # -- vision / image ----------------------------------------------------- # - - def describe_image(self, image_bytes: bytes, mime_type: str) -> str: - encoded = base64.b64encode(image_bytes).decode("utf-8") - body = { - "anthropic_version": "bedrock-2023-05-31", - "max_tokens": 512, - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Describe this image for a Slack conversation."}, - {"type": "image", "source": {"type": "base64", "media_type": mime_type, "data": encoded}}, - ], - } - ], - } - client = self._get_client() - response = client.invoke_model(modelId=self.model, body=json.dumps(body)) - payload = json.loads(response["body"].read()) - for block in payload.get("content", []): - if block.get("type") == "text": - return block.get("text", "") - return "" - - def generate_image(self, prompt: str) -> bytes: - body = self._build_image_body(prompt) - client = self._get_client() - response = client.invoke_model(modelId=self.image_model, body=json.dumps(body)) - payload = json.loads(response["body"].read()) - return self._extract_image_bytes(payload) - - def _build_image_body(self, prompt: str) -> dict[str, Any]: - family = self._image_family - if family.startswith("amazon.titan-image") or family.startswith("amazon.nova-canvas"): - return { - "taskType": "TEXT_IMAGE", - "textToImageParams": {"text": prompt}, - "imageGenerationConfig": {"numberOfImages": 1, "quality": "standard", "height": 1024, "width": 1024}, - } - if family.startswith("stability."): - return {"text_prompts": [{"text": prompt}], "cfg_scale": 7, "steps": 30, "seed": 0} - raise ValueError(f"unsupported Bedrock image model: {self.image_model}") - - def _extract_image_bytes(self, payload: dict[str, Any]) -> bytes: - if "images" in payload and payload["images"]: - return base64.b64decode(payload["images"][0]) - if "artifacts" in payload and payload["artifacts"]: - return base64.b64decode(payload["artifacts"][0]["base64"]) - raise ValueError("no image returned from Bedrock") - - # -- format helpers ----------------------------------------------------- # - - @staticmethod - def _to_anthropic_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Translate our canonical messages format into Anthropic Messages API shape. - - Our format mirrors OpenAI's: role=user/assistant/tool, content can be str - or list. We map `tool` role to a user message with a tool_result block, - and `assistant` messages with tool_calls into tool_use content blocks. - """ - out: list[dict[str, Any]] = [] - for msg in messages: - role = msg.get("role") - if role == "tool": - out.append( - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": msg.get("tool_call_id", ""), - "content": msg.get("content", ""), - } - ], - } - ) - elif role == "assistant" and msg.get("tool_calls"): - blocks: list[dict[str, Any]] = [] - if msg.get("content"): - blocks.append({"type": "text", "text": msg["content"]}) - for tc in msg["tool_calls"]: - blocks.append( - {"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})} - ) - out.append({"role": "assistant", "content": blocks}) - else: - out.append({"role": role or "user", "content": msg.get("content", "")}) - return out - - @staticmethod - def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [] - for msg in messages: - role = msg.get("role") - if role == "tool": - out.append( - { - "role": "user", - "content": [ - { - "toolResult": { - "toolUseId": msg.get("tool_call_id", ""), - "content": [{"text": str(msg.get("content", ""))}], - } - } - ], - } - ) - elif role == "assistant" and msg.get("tool_calls"): - blocks: list[dict[str, Any]] = [] - if msg.get("content"): - blocks.append({"text": msg["content"]}) - for tc in msg["tool_calls"]: - blocks.append({"toolUse": {"toolUseId": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})}}) - out.append({"role": "assistant", "content": blocks}) - else: - out.append({"role": role or "user", "content": [{"text": str(msg.get("content", ""))}]}) - return out - - -# --------------------------------------------------------------------------- # -# Factory -# --------------------------------------------------------------------------- # - - -def get_llm( - provider: str, - model: str, - image_provider: str, - image_model: str, - region: str = "us-east-1", - api_keys: dict[str, str | None] | None = None, -) -> LLMProvider: - """Build an LLM client for the requested provider(s). - - `api_keys` carries per-provider keys that need explicit wiring (xAI today; - OpenAI reads OPENAI_API_KEY from env directly, Bedrock uses the AWS SDK - credential chain). - """ - api_keys = api_keys or {} - - def build(p: str) -> LLMProvider: - if p == "bedrock": - return BedrockProvider(model=model, image_model=image_model, region=region) - if p == "xai": - return XAIProvider(model=model, image_model=image_model, api_key=api_keys.get("xai")) - return OpenAIProvider(model=model, image_model=image_model) - - text = build(provider) - if image_provider == provider: - return text - return _CompositeProvider(text=text, image=build(image_provider)) - - -@dataclass -class _CompositeProvider: - """Delegates generate_image to a different provider than chat/vision.""" - - text: LLMProvider - image: LLMProvider - - def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): - return self.text.chat(system, messages, tools=tools, max_tokens=max_tokens, on_delta=on_delta) - - def stream_chat(self, system, messages, on_delta, max_tokens=1024): - return self.text.stream_chat(system, messages, on_delta, max_tokens=max_tokens) - - def describe_image(self, image_bytes, mime_type): - return self.text.describe_image(image_bytes, mime_type) - - def generate_image(self, prompt): - return self.image.generate_image(prompt) diff --git a/src/llms/__init__.py b/src/llms/__init__.py new file mode 100644 index 0000000..b8de56e --- /dev/null +++ b/src/llms/__init__.py @@ -0,0 +1,16 @@ +"""LLM provider package. + +Public re-exports. Importing submodule internals directly is fine for +provider-specific wiring (e.g. `from src.llms.bedrock import BedrockProvider` +in tests), but callers should prefer the names exported here. +""" +from src.llms.base import LLMProvider, LLMResult, ToolCall, ToolSpec +from src.llms.factory import get_llm + +__all__ = [ + "LLMProvider", + "LLMResult", + "ToolCall", + "ToolSpec", + "get_llm", +] diff --git a/src/llms/base.py b/src/llms/base.py new file mode 100644 index 0000000..723f633 --- /dev/null +++ b/src/llms/base.py @@ -0,0 +1,80 @@ +"""Shared protocol, dataclasses, and retry helper for LLM providers.""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Literal, Protocol + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Types +# --------------------------------------------------------------------------- # + +ToolSpec = dict[str, Any] # {"name","description","parameters"(JSON Schema)} + + +@dataclass +class ToolCall: + id: str + name: str + arguments: dict[str, Any] + + +@dataclass +class LLMResult: + content: str + tool_calls: list[ToolCall] = field(default_factory=list) + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] = "end_turn" + token_usage: dict[str, int] = field(default_factory=dict) + + +class LLMProvider(Protocol): + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: ... + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: ... + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: ... + + def generate_image(self, prompt: str) -> bytes: ... + + +# --------------------------------------------------------------------------- # +# Retry helper +# --------------------------------------------------------------------------- # + +_RETRYABLE_BEDROCK = {"ThrottlingException", "ServiceQuotaExceededException", "ModelTimeoutException"} + + +def _with_retry(fn: Callable[[], Any], label: str, attempts: int = 3) -> Any: + delay = 1.0 + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return fn() + except Exception as exc: # noqa: BLE001 + last_exc = exc + code = getattr(getattr(exc, "response", None), "get", lambda _k, _d=None: None)("Error", {}).get("Code") if hasattr(exc, "response") else None + if code in _RETRYABLE_BEDROCK and attempt < attempts - 1: + logger.warning("%s retryable (%s), backoff %.1fs", label, code, delay) + time.sleep(delay) + delay *= 2 + continue + raise + if last_exc: + raise last_exc diff --git a/src/llms/bedrock.py b/src/llms/bedrock.py new file mode 100644 index 0000000..864e5ca --- /dev/null +++ b/src/llms/bedrock.py @@ -0,0 +1,363 @@ +"""BedrockProvider — family-routed across Anthropic Claude, Amazon Nova, Stability.""" +from __future__ import annotations + +import base64 +import json +import logging +import uuid +from typing import Any, Callable, Literal + +import boto3 + +from src.llms.base import LLMResult, ToolCall, ToolSpec, _with_retry + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Bedrock +# --------------------------------------------------------------------------- # + + +_INFERENCE_PROFILE_PREFIXES = ("us.", "eu.", "apac.", "global.") + + +def _strip_inference_profile_prefix(model_id: str) -> str: + """Return the bare family id from a Bedrock model or inference-profile id. + + Inference profile IDs prefix the family with a region routing hint, e.g. + `us.anthropic.claude-haiku-4-5-20251001-v1:0`. For family-level routing + ("is this a Claude? a Nova? Titan?") we care about the bare portion. + """ + for p in _INFERENCE_PROFILE_PREFIXES: + if model_id.startswith(p): + return model_id[len(p):] + return model_id + + +class BedrockProvider: + def __init__(self, model: str, image_model: str, region: str): + self.model = model + self.image_model = image_model + self.region = region + self._client = None + + def _get_client(self): + if self._client is None: + self._client = boto3.client("bedrock-runtime", region_name=self.region) + return self._client + + @property + def _text_family(self) -> str: + return _strip_inference_profile_prefix(self.model) + + @property + def _image_family(self) -> str: + return _strip_inference_profile_prefix(self.image_model) + + # -- text / tool use ---------------------------------------------------- # + + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: + # Bedrock tool_use streaming is not yet implemented in this provider; + # accept the on_delta parameter for API compatibility but use the + # blocking path, then emit the final content as a single delta so + # callers still receive *something* through the streaming channel. + family = self._text_family + if family.startswith("anthropic.claude"): + result = self._claude_chat(system, messages, tools, max_tokens) + elif family.startswith("amazon.nova"): + result = self._nova_chat(system, messages, tools, max_tokens) + else: + result = self._claude_chat(system, messages, None, max_tokens) + if on_delta is not None and result.content and not result.tool_calls: + on_delta(result.content) + return result + + def _claude_chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None, + max_tokens: int, + ) -> LLMResult: + body: dict[str, Any] = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system, + "messages": self._to_anthropic_messages(messages), + } + if tools: + body["tools"] = [ + {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} + for t in tools + ] + + client = self._get_client() + response = _with_retry( + lambda: client.invoke_model(modelId=self.model, body=json.dumps(body)), + label="bedrock.invoke_model", + ) + payload = json.loads(response["body"].read()) + + content_text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in payload.get("content", []): + if block.get("type") == "text": + content_text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_use": + tool_calls.append( + ToolCall( + id=block.get("id", str(uuid.uuid4())), + name=block.get("name", ""), + arguments=block.get("input", {}) or {}, + ) + ) + + stop_reason_raw = payload.get("stop_reason", "end_turn") + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] + if stop_reason_raw == "tool_use": + stop_reason = "tool_use" + elif stop_reason_raw == "max_tokens": + stop_reason = "max_tokens" + elif stop_reason_raw == "end_turn": + stop_reason = "end_turn" + else: + stop_reason = "other" + + usage = payload.get("usage") or {} + return LLMResult( + content="".join(content_text_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + token_usage={"input": usage.get("input_tokens", 0) or 0, "output": usage.get("output_tokens", 0) or 0}, + ) + + def _nova_chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None, + max_tokens: int, + ) -> LLMResult: + client = self._get_client() + payload: dict[str, Any] = { + "modelId": self.model, + "system": [{"text": system}], + "messages": self._to_nova_messages(messages), + "inferenceConfig": {"maxTokens": max_tokens, "temperature": 0.2}, + } + if tools: + payload["toolConfig"] = { + "tools": [ + {"toolSpec": {"name": t["name"], "description": t["description"], "inputSchema": {"json": t["parameters"]}}} + for t in tools + ] + } + + response = _with_retry(lambda: client.converse(**payload), label="bedrock.converse") + out_msg = response.get("output", {}).get("message", {}) + content_text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in out_msg.get("content", []): + if "text" in block: + content_text_parts.append(block["text"]) + elif "toolUse" in block: + tu = block["toolUse"] + tool_calls.append( + ToolCall(id=tu.get("toolUseId") or str(uuid.uuid4()), name=tu.get("name", ""), arguments=tu.get("input", {}) or {}) + ) + + stop_reason_raw = response.get("stopReason", "end_turn") + stop_reason: Literal["end_turn", "tool_use", "max_tokens", "other"] + if stop_reason_raw == "tool_use": + stop_reason = "tool_use" + elif stop_reason_raw == "max_tokens": + stop_reason = "max_tokens" + elif stop_reason_raw == "end_turn": + stop_reason = "end_turn" + else: + stop_reason = "other" + + usage = response.get("usage") or {} + return LLMResult( + content="".join(content_text_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + token_usage={"input": usage.get("inputTokens", 0) or 0, "output": usage.get("outputTokens", 0) or 0}, + ) + + # -- streaming --------------------------------------------------------- # + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: + # Bedrock streaming implementation: Claude Messages stream or Converse stream. + client = self._get_client() + full = "" + family = self._text_family + if family.startswith("anthropic.claude"): + body = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system, + "messages": self._to_anthropic_messages(messages), + } + response = client.invoke_model_with_response_stream(modelId=self.model, body=json.dumps(body)) + for event in response.get("body", []): + chunk = event.get("chunk", {}) + if not chunk: + continue + payload = json.loads(chunk.get("bytes", b"{}")) + if payload.get("type") == "content_block_delta": + delta = (payload.get("delta") or {}).get("text") or "" + if delta: + full += delta + on_delta(delta) + return full + + # Nova Converse stream + response = client.converse_stream( + modelId=self.model, + system=[{"text": system}], + messages=self._to_nova_messages(messages), + inferenceConfig={"maxTokens": max_tokens, "temperature": 0.2}, + ) + for event in response.get("stream", []): + cbd = event.get("contentBlockDelta") + if cbd: + delta = (cbd.get("delta") or {}).get("text") or "" + if delta: + full += delta + on_delta(delta) + return full + + # -- vision / image ----------------------------------------------------- # + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + encoded = base64.b64encode(image_bytes).decode("utf-8") + body = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 512, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image for a Slack conversation."}, + {"type": "image", "source": {"type": "base64", "media_type": mime_type, "data": encoded}}, + ], + } + ], + } + client = self._get_client() + response = client.invoke_model(modelId=self.model, body=json.dumps(body)) + payload = json.loads(response["body"].read()) + for block in payload.get("content", []): + if block.get("type") == "text": + return block.get("text", "") + return "" + + def generate_image(self, prompt: str) -> bytes: + body = self._build_image_body(prompt) + client = self._get_client() + response = client.invoke_model(modelId=self.image_model, body=json.dumps(body)) + payload = json.loads(response["body"].read()) + return self._extract_image_bytes(payload) + + def _build_image_body(self, prompt: str) -> dict[str, Any]: + family = self._image_family + if family.startswith("amazon.titan-image") or family.startswith("amazon.nova-canvas"): + return { + "taskType": "TEXT_IMAGE", + "textToImageParams": {"text": prompt}, + "imageGenerationConfig": {"numberOfImages": 1, "quality": "standard", "height": 1024, "width": 1024}, + } + if family.startswith("stability."): + return {"text_prompts": [{"text": prompt}], "cfg_scale": 7, "steps": 30, "seed": 0} + raise ValueError(f"unsupported Bedrock image model: {self.image_model}") + + def _extract_image_bytes(self, payload: dict[str, Any]) -> bytes: + if "images" in payload and payload["images"]: + return base64.b64decode(payload["images"][0]) + if "artifacts" in payload and payload["artifacts"]: + return base64.b64decode(payload["artifacts"][0]["base64"]) + raise ValueError("no image returned from Bedrock") + + # -- format helpers ----------------------------------------------------- # + + @staticmethod + def _to_anthropic_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate our canonical messages format into Anthropic Messages API shape. + + Our format mirrors OpenAI's: role=user/assistant/tool, content can be str + or list. We map `tool` role to a user message with a tool_result block, + and `assistant` messages with tool_calls into tool_use content blocks. + """ + out: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + out.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg.get("tool_call_id", ""), + "content": msg.get("content", ""), + } + ], + } + ) + elif role == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"type": "text", "text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append( + {"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})} + ) + out.append({"role": "assistant", "content": blocks}) + else: + out.append({"role": role or "user", "content": msg.get("content", "")}) + return out + + @staticmethod + def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + if role == "tool": + out.append( + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": msg.get("tool_call_id", ""), + "content": [{"text": str(msg.get("content", ""))}], + } + } + ], + } + ) + elif role == "assistant" and msg.get("tool_calls"): + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append({"toolUse": {"toolUseId": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})}}) + out.append({"role": "assistant", "content": blocks}) + else: + out.append({"role": role or "user", "content": [{"text": str(msg.get("content", ""))}]}) + return out diff --git a/src/llms/composite.py b/src/llms/composite.py new file mode 100644 index 0000000..8244b6e --- /dev/null +++ b/src/llms/composite.py @@ -0,0 +1,26 @@ +"""CompositeProvider — wrap separate text and image providers.""" +from __future__ import annotations + +from dataclasses import dataclass + +from src.llms.base import LLMProvider, LLMResult + + +@dataclass +class _CompositeProvider: + """Delegates generate_image to a different provider than chat/vision.""" + + text: LLMProvider + image: LLMProvider + + def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): + return self.text.chat(system, messages, tools=tools, max_tokens=max_tokens, on_delta=on_delta) + + def stream_chat(self, system, messages, on_delta, max_tokens=1024): + return self.text.stream_chat(system, messages, on_delta, max_tokens=max_tokens) + + def describe_image(self, image_bytes, mime_type): + return self.text.describe_image(image_bytes, mime_type) + + def generate_image(self, prompt): + return self.image.generate_image(prompt) diff --git a/src/llms/factory.py b/src/llms/factory.py new file mode 100644 index 0000000..5c931f7 --- /dev/null +++ b/src/llms/factory.py @@ -0,0 +1,38 @@ +"""get_llm — select provider(s) from Settings.""" +from __future__ import annotations + +from src.config import Settings +from src.llms.base import LLMProvider +from src.llms.bedrock import BedrockProvider +from src.llms.composite import _CompositeProvider +from src.llms.openai import OpenAIProvider +from src.llms.xai import XAIProvider + + +def get_llm( + provider: str, + model: str, + image_provider: str, + image_model: str, + region: str = "us-east-1", + api_keys: dict[str, str | None] | None = None, +) -> LLMProvider: + """Build an LLM client for the requested provider(s). + + `api_keys` carries per-provider keys that need explicit wiring (xAI today; + OpenAI reads OPENAI_API_KEY from env directly, Bedrock uses the AWS SDK + credential chain). + """ + api_keys = api_keys or {} + + def build(p: str) -> LLMProvider: + if p == "bedrock": + return BedrockProvider(model=model, image_model=image_model, region=region) + if p == "xai": + return XAIProvider(model=model, image_model=image_model, api_key=api_keys.get("xai")) + return OpenAIProvider(model=model, image_model=image_model) + + text = build(provider) + if image_provider == provider: + return text + return _CompositeProvider(text=text, image=build(image_provider)) diff --git a/src/llms/openai.py b/src/llms/openai.py new file mode 100644 index 0000000..63843d2 --- /dev/null +++ b/src/llms/openai.py @@ -0,0 +1,17 @@ +"""OpenAIProvider — default OpenAI endpoint with vision and gpt-image-1.""" +from __future__ import annotations + +from src.llms.openai_wire import _OpenAICompatProvider, _is_new_gen_openai +from typing import Any + + +class OpenAIProvider(_OpenAICompatProvider): + BASE_URL = None # default OpenAI endpoint + API_KEY_ENV_VAR = "OPENAI_API_KEY" + + def _token_params(self, max_tokens: int) -> dict[str, Any]: + # Newer OpenAI reasoning models only accept max_completion_tokens and + # reject `temperature`. Legacy chat models still use max_tokens. + if _is_new_gen_openai(self.model): + return {"max_completion_tokens": max_tokens} + return {"max_tokens": max_tokens, "temperature": 0.2} diff --git a/src/llms/openai_wire.py b/src/llms/openai_wire.py new file mode 100644 index 0000000..3c6cf7f --- /dev/null +++ b/src/llms/openai_wire.py @@ -0,0 +1,293 @@ +"""OpenAI-wire helpers and the OpenAICompat base provider. + +OpenAI and xAI both speak the OpenAI chat-completions wire format, so their +providers share this module. BedrockProvider does NOT use any of these. +""" +from __future__ import annotations + +import base64 +import json +import logging +import time +import uuid +from typing import Any, Callable, Literal + +from src.llms.base import LLMResult, ToolCall, ToolSpec + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# OpenAI +# --------------------------------------------------------------------------- # + + +_OPENAI_NEW_GENERATION_PREFIXES = ("gpt-5", "o1", "o3", "o4") + + +def _is_new_gen_openai(model: str) -> bool: + """Newer OpenAI models (gpt-5, o1/o3/o4 reasoning) use `max_completion_tokens` + and disallow `temperature` overrides.""" + return any(model.startswith(p) for p in _OPENAI_NEW_GENERATION_PREFIXES) + + +# --------------------------------------------------------------------------- # +# Module-level helpers shared between OpenAI-compatible providers (OpenAI, xAI) +# --------------------------------------------------------------------------- # + + +def _to_openai_wire_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate canonical messages (our agent's shape) to OpenAI's wire shape.""" + out: list[dict[str, Any]] = [] + for msg in messages: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + out.append( + { + "role": "assistant", + "content": msg.get("content") or None, + "tool_calls": [ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": json.dumps(tc.get("arguments") or {}, ensure_ascii=False), + }, + } + for tc in msg["tool_calls"] + ], + } + ) + else: + out.append(msg) + return out + + +def _build_openai_tools_payload(tools: list[ToolSpec]) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }, + } + for t in tools + ] + + +def _map_openai_finish_reason(finish: str | None) -> Literal["end_turn", "tool_use", "max_tokens", "other"]: + if finish == "tool_calls": + return "tool_use" + if finish == "length": + return "max_tokens" + if finish in {"stop", None}: + return "end_turn" + return "other" + + +def _extract_openai_usage(usage_obj) -> dict[str, int]: + if not usage_obj: + return {} + return { + "input": getattr(usage_obj, "prompt_tokens", 0) or 0, + "output": getattr(usage_obj, "completion_tokens", 0) or 0, + } + + +def _parse_openai_completion(completion) -> LLMResult: + choice = completion.choices[0] + msg = choice.message + tool_calls: list[ToolCall] = [] + for call in (msg.tool_calls or []): + try: + args = json.loads(call.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall(id=call.id, name=call.function.name, arguments=args)) + + return LLMResult( + content=msg.content or "", + tool_calls=tool_calls, + stop_reason=_map_openai_finish_reason(choice.finish_reason), + token_usage=_extract_openai_usage(getattr(completion, "usage", None)), + ) + + +def _consume_openai_stream(stream, on_delta: Callable[[str], None]) -> LLMResult: + """Drain an OpenAI-compatible chat completion stream. + + Stops forwarding content to `on_delta` once a tool_calls delta arrives — + any trailing commentary would otherwise leak into the final user reply. + tool_calls chunks are accumulated by index and returned as ToolCall list. + """ + content_parts: list[str] = [] + tool_calls_accum: dict[int, dict[str, Any]] = {} + saw_tool_calls = False + finish_reason: str | None = None + usage_obj = None + + for chunk in stream: + usage_obj = getattr(chunk, "usage", None) or usage_obj + if not chunk.choices: + continue + choice = chunk.choices[0] + delta = choice.delta + if getattr(delta, "tool_calls", None): + saw_tool_calls = True + for tc in delta.tool_calls: + idx = tc.index + slot = tool_calls_accum.setdefault(idx, {"id": None, "name": "", "arguments": ""}) + if getattr(tc, "id", None): + slot["id"] = tc.id + fn = getattr(tc, "function", None) + if fn is not None: + if getattr(fn, "name", None): + slot["name"] += fn.name + if getattr(fn, "arguments", None): + slot["arguments"] += fn.arguments + if getattr(delta, "content", None): + content_parts.append(delta.content) + if not saw_tool_calls: + on_delta(delta.content) + if getattr(choice, "finish_reason", None): + finish_reason = choice.finish_reason + + tool_calls: list[ToolCall] = [] + for idx in sorted(tool_calls_accum): + slot = tool_calls_accum[idx] + try: + args = json.loads(slot["arguments"] or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCall(id=slot["id"] or "", name=slot["name"], arguments=args)) + + return LLMResult( + content="".join(content_parts), + tool_calls=tool_calls, + stop_reason=_map_openai_finish_reason(finish_reason), + token_usage=_extract_openai_usage(usage_obj), + ) + + +class _OpenAICompatProvider: + """Shared machinery for any OpenAI-wire-compatible chat/vision/image API. + + Subclasses set BASE_URL / API_KEY_ENV_VAR and override small hooks + (`_token_params`, `_image_generate_kwargs`). The heavy lifting — + payload assembly, streaming, tool_calls parsing — lives on this base + and on the module-level helpers above. + """ + + BASE_URL: str | None = None # None = OpenAI default + API_KEY_ENV_VAR: str = "OPENAI_API_KEY" + + def __init__(self, model: str, image_model: str, api_key: str | None = None): + self.model = model + self.image_model = image_model + self._api_key = api_key + self._client = None + + def _get_client(self): + if self._client is None: + from openai import OpenAI + + kwargs: dict[str, Any] = {} + if self.BASE_URL: + kwargs["base_url"] = self.BASE_URL + if self._api_key: + kwargs["api_key"] = self._api_key + self._client = OpenAI(**kwargs) + return self._client + + # -- hooks -------------------------------------------------------------- # + + def _token_params(self, max_tokens: int) -> dict[str, Any]: + """Default: OpenAI legacy models use max_tokens+temperature.""" + return {"max_tokens": max_tokens, "temperature": 0.2} + + def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: + """Default OpenAI (dall-e / gpt-image-1) image call kwargs.""" + kwargs: dict[str, Any] = { + "model": self.image_model, + "prompt": prompt, + "size": "1024x1024", + } + # gpt-image-1 rejects `response_format` (b64 is the default); only legacy + # DALL-E models need the explicit flag. + if self.image_model.startswith("dall-e"): + kwargs["response_format"] = "b64_json" + return kwargs + + # -- LLMProvider surface ----------------------------------------------- # + + def chat( + self, + system: str, + messages: list[dict[str, Any]], + tools: list[ToolSpec] | None = None, + max_tokens: int = 1024, + on_delta: Callable[[str], None] | None = None, + ) -> LLMResult: + client = self._get_client() + payload: dict[str, Any] = { + "model": self.model, + "messages": [{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], + **self._token_params(max_tokens), + } + if tools: + payload["tools"] = _build_openai_tools_payload(tools) + payload["tool_choice"] = "auto" + + if on_delta is None: + completion = client.chat.completions.create(**payload) + return _parse_openai_completion(completion) + + payload = {**payload, "stream": True, "stream_options": {"include_usage": True}} + stream = client.chat.completions.create(**payload) + return _consume_openai_stream(stream, on_delta) + + def stream_chat( + self, + system: str, + messages: list[dict[str, Any]], + on_delta: Callable[[str], None], + max_tokens: int = 1024, + ) -> str: + client = self._get_client() + stream = client.chat.completions.create( + model=self.model, + messages=[{"role": "system", "content": system}, *_to_openai_wire_messages(messages)], + stream=True, + **self._token_params(max_tokens), + ) + full = "" + for chunk in stream: + delta = chunk.choices[0].delta.content if chunk.choices else None + if delta: + full += delta + on_delta(delta) + return full + + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + client = self._get_client() + encoded = base64.b64encode(image_bytes).decode("utf-8") + completion = client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image for a Slack conversation."}, + {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{encoded}"}}, + ], + } + ], + ) + return completion.choices[0].message.content or "" + + def generate_image(self, prompt: str) -> bytes: + client = self._get_client() + response = client.images.generate(**self._image_generate_kwargs(prompt)) + return base64.b64decode(response.data[0].b64_json) diff --git a/src/llms/xai.py b/src/llms/xai.py new file mode 100644 index 0000000..23a7e2a --- /dev/null +++ b/src/llms/xai.py @@ -0,0 +1,33 @@ +"""XAIProvider — Grok chat + grok-imagine at https://api.x.ai/v1.""" +from __future__ import annotations + +from typing import Any + +from src.llms.openai_wire import _OpenAICompatProvider + + +class XAIProvider(_OpenAICompatProvider): + """xAI (Grok) — OpenAI-wire compatible, different base URL and image params. + + Models: + text: grok-4-1-fast-reasoning, grok-4.20-0309-reasoning, ... + image: grok-imagine-image, grok-imagine-image-pro + + Differences from OpenAI that matter here: + - `images.generate` rejects `size` (uses `aspect_ratio`/`resolution`). + We omit `size` and request `response_format=b64_json` so we can + decode bytes locally, matching the rest of the pipeline. + - All current grok chat models accept `max_tokens` + `temperature` + the classic way — no `max_completion_tokens` split. + """ + + BASE_URL = "https://api.x.ai/v1" + API_KEY_ENV_VAR = "XAI_API_KEY" + + def _image_generate_kwargs(self, prompt: str) -> dict[str, Any]: + return { + "model": self.image_model, + "prompt": prompt, + "n": 1, + "response_format": "b64_json", + } diff --git a/src/tools.py b/src/tools.py index 8e3415d..c364c82 100644 --- a/src/tools.py +++ b/src/tools.py @@ -22,7 +22,7 @@ from slack_sdk.errors import SlackApiError from src.config import Settings -from src.llm import LLMProvider, ToolCall +from src.llms import LLMProvider, ToolCall from src.slack_helpers import user_name_cache # Re-export web-fetch helpers so callers (and tests that still import from diff --git a/tests/test_agent.py b/tests/test_agent.py index b962deb..281586f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock from src.agent import SlackMentionAgent -from src.llm import LLMResult, ToolCall +from src.llms import LLMResult, ToolCall from src.tools import ToolContext, ToolRegistry, tool diff --git a/tests/test_llm.py b/tests/test_llm.py index a5e9191..7dc4deb 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,7 +1,10 @@ import json from unittest.mock import MagicMock, patch -from src.llm import BedrockProvider, OpenAIProvider, ToolCall, XAIProvider, get_llm +from src.llms import ToolCall, get_llm +from src.llms.bedrock import BedrockProvider +from src.llms.openai import OpenAIProvider +from src.llms.xai import XAIProvider # --------------------------------------------------------------------------- # @@ -512,7 +515,7 @@ def test_openai_generate_image_dalle_sends_response_format(): def test_composite_provider_routes_image_to_image_llm(): - from src.llm import _CompositeProvider + from src.llms.composite import _CompositeProvider text = MagicMock() image = MagicMock() @@ -633,7 +636,7 @@ def test_get_llm_builds_xai_provider(): def test_get_llm_composite_xai_text_openai_image(): """Mixed-provider setups still work through _CompositeProvider.""" - from src.llm import _CompositeProvider + from src.llms.composite import _CompositeProvider provider = get_llm( provider="xai", diff --git a/tests/test_tools.py b/tests/test_tools.py index a28854d..1d9db43 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -4,7 +4,7 @@ import pytest from src.config import Settings -from src.llm import ToolCall +from src.llms import ToolCall from src.tools import ( ToolContext, ToolExecutor, From 2a07249420a55be711edcc2454a055802aef311e Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:30:09 +0900 Subject: [PATCH 27/65] refactor(llms): drop unused imports introduced by the package split --- src/llms/composite.py | 2 +- src/llms/factory.py | 3 +-- src/llms/openai.py | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/llms/composite.py b/src/llms/composite.py index 8244b6e..6b84395 100644 --- a/src/llms/composite.py +++ b/src/llms/composite.py @@ -3,7 +3,7 @@ from dataclasses import dataclass -from src.llms.base import LLMProvider, LLMResult +from src.llms.base import LLMProvider @dataclass diff --git a/src/llms/factory.py b/src/llms/factory.py index 5c931f7..098f6cf 100644 --- a/src/llms/factory.py +++ b/src/llms/factory.py @@ -1,7 +1,6 @@ -"""get_llm — select provider(s) from Settings.""" +"""get_llm — build an LLM provider from explicit parameters.""" from __future__ import annotations -from src.config import Settings from src.llms.base import LLMProvider from src.llms.bedrock import BedrockProvider from src.llms.composite import _CompositeProvider diff --git a/src/llms/openai.py b/src/llms/openai.py index 63843d2..7af4e08 100644 --- a/src/llms/openai.py +++ b/src/llms/openai.py @@ -1,9 +1,10 @@ """OpenAIProvider — default OpenAI endpoint with vision and gpt-image-1.""" from __future__ import annotations -from src.llms.openai_wire import _OpenAICompatProvider, _is_new_gen_openai from typing import Any +from src.llms.openai_wire import _OpenAICompatProvider, _is_new_gen_openai + class OpenAIProvider(_OpenAICompatProvider): BASE_URL = None # default OpenAI endpoint From 9102a88bca7503b077b97d57bc7afd3b968d214d Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:34:12 +0900 Subject: [PATCH 28/65] refactor(tests): split tests/test_llm.py into tests/llms package Co-Authored-By: Claude Sonnet 4.6 --- tests/llms/__init__.py | 0 tests/llms/test_base.py | 2 + tests/llms/test_bedrock.py | 225 ++++++++++++ tests/llms/test_composite.py | 17 + tests/llms/test_factory.py | 35 ++ tests/llms/test_openai.py | 276 ++++++++++++++ tests/llms/test_openai_wire.py | 2 + tests/llms/test_xai.py | 111 ++++++ tests/test_llm.py | 651 --------------------------------- 9 files changed, 668 insertions(+), 651 deletions(-) create mode 100644 tests/llms/__init__.py create mode 100644 tests/llms/test_base.py create mode 100644 tests/llms/test_bedrock.py create mode 100644 tests/llms/test_composite.py create mode 100644 tests/llms/test_factory.py create mode 100644 tests/llms/test_openai.py create mode 100644 tests/llms/test_openai_wire.py create mode 100644 tests/llms/test_xai.py delete mode 100644 tests/test_llm.py diff --git a/tests/llms/__init__.py b/tests/llms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/llms/test_base.py b/tests/llms/test_base.py new file mode 100644 index 0000000..219810b --- /dev/null +++ b/tests/llms/test_base.py @@ -0,0 +1,2 @@ +"""Tests for src.llms.base.""" +from __future__ import annotations diff --git a/tests/llms/test_bedrock.py b/tests/llms/test_bedrock.py new file mode 100644 index 0000000..f3f4b70 --- /dev/null +++ b/tests/llms/test_bedrock.py @@ -0,0 +1,225 @@ +"""Tests for src.llms.bedrock.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest + +from src.llms.bedrock import BedrockProvider + + +def _bedrock_response(payload: dict): + body = MagicMock() + body.read.return_value = json.dumps(payload).encode() + return {"body": body} + + +def test_bedrock_inference_profile_routes_to_claude(): + """us.anthropic.claude-* inference profile IDs must still hit the Claude + path (tool_use, messages API), not the unknown-family fallback.""" + provider = BedrockProvider( + model="us.anthropic.claude-opus-4-6-v1", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 2}, + } + ) + tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] + result = provider.chat(system="s", messages=[], tools=tools) + assert result.content == "hi" + # Claude body carries tools (not Nova's toolConfig) + body = provider._client.invoke_model.call_args.kwargs["body"] + import json as _json + + parsed = _json.loads(body) + assert "tools" in parsed # routed into _claude_chat, not fallback + assert parsed["tools"][0]["name"] == "search_web" + + +def test_bedrock_inference_profile_image_routing(): + """global./us. prefixed image model IDs still reach the Titan/Nova body.""" + provider = BedrockProvider( + model="us.anthropic.claude-opus-4-6-v1", + image_model="us.amazon.nova-canvas-v1:0", + region="us-east-1", + ) + body = provider._build_image_body("cat") + assert body["taskType"] == "TEXT_IMAGE" + + +def test_bedrock_claude_chat_text(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [{"type": "text", "text": "안녕"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 7}, + } + ) + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "안녕" + assert result.stop_reason == "end_turn" + assert result.token_usage == {"input": 5, "output": 7} + + +def test_bedrock_claude_chat_with_tool_use(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + { + "content": [ + {"type": "text", "text": "I'll search."}, + {"type": "tool_use", "id": "tu_1", "name": "search_web", "input": {"query": "x"}}, + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 3, "output_tokens": 4}, + } + ) + tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] + result = provider.chat(system="s", messages=[], tools=tools) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "x"} + + +def test_bedrock_message_translation_tool_role(): + messages = [ + {"role": "user", "content": "ask"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "t1", "name": "foo", "arguments": {"a": 1}}]}, + {"role": "tool", "tool_call_id": "t1", "content": "{\"ok\":true}"}, + ] + translated = BedrockProvider._to_anthropic_messages(messages) + assert translated[0] == {"role": "user", "content": "ask"} + assert translated[1]["role"] == "assistant" + assert translated[1]["content"][0]["type"] == "tool_use" + assert translated[1]["content"][0]["name"] == "foo" + assert translated[2]["role"] == "user" + assert translated[2]["content"][0]["type"] == "tool_result" + + +def test_bedrock_nova_chat_text(): + provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 2}, + } + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "hi" + assert result.stop_reason == "end_turn" + assert result.token_usage == {"input": 1, "output": 2} + + +def test_bedrock_nova_tool_use(): + provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": { + "message": { + "content": [ + {"text": "let me search"}, + {"toolUse": {"toolUseId": "tu1", "name": "search_web", "input": {"query": "q"}}}, + ] + } + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 2, "outputTokens": 3}, + } + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + ) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + + +def test_build_image_body_titan(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + body = provider._build_image_body("a cat") + assert body["taskType"] == "TEXT_IMAGE" + + +def test_build_image_body_stability(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="stability.stable-diffusion-xl-v1", + region="us-east-1", + ) + body = provider._build_image_body("a cat") + assert body["text_prompts"][0]["text"] == "a cat" + + +def test_build_image_body_unknown_raises(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="mystery.v1", + region="us-east-1", + ) + with pytest.raises(ValueError): + provider._build_image_body("x") + + +def test_bedrock_describe_image_returns_text(): + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"content": [{"type": "text", "text": "a cat"}]} + ) + out = provider.describe_image(b"fake", "image/png") + assert out == "a cat" + + +def test_bedrock_generate_image_titan_returns_bytes(): + import base64 as _b64 + + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="amazon.titan-image-generator-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"images": [_b64.b64encode(b"imgdata").decode()]} + ) + assert provider.generate_image("cat") == b"imgdata" + + +def test_bedrock_generate_image_stability_returns_bytes(): + import base64 as _b64 + + provider = BedrockProvider( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + image_model="stability.stable-diffusion-xl-v1", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.invoke_model.return_value = _bedrock_response( + {"artifacts": [{"base64": _b64.b64encode(b"xyz").decode()}]} + ) + assert provider.generate_image("cat") == b"xyz" diff --git a/tests/llms/test_composite.py b/tests/llms/test_composite.py new file mode 100644 index 0000000..c3938c3 --- /dev/null +++ b/tests/llms/test_composite.py @@ -0,0 +1,17 @@ +"""Tests for src.llms.composite.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from src.llms.composite import _CompositeProvider + + +def test_composite_provider_routes_image_to_image_llm(): + text = MagicMock() + image = MagicMock() + image.generate_image.return_value = b"img" + composite = _CompositeProvider(text=text, image=image) + composite.chat(system="s", messages=[]) + text.chat.assert_called_once() + composite.generate_image("x") + image.generate_image.assert_called_once_with("x") diff --git a/tests/llms/test_factory.py b/tests/llms/test_factory.py new file mode 100644 index 0000000..7d982a2 --- /dev/null +++ b/tests/llms/test_factory.py @@ -0,0 +1,35 @@ +"""Tests for src.llms.factory (get_llm).""" +from __future__ import annotations + +from src.llms import get_llm +from src.llms.composite import _CompositeProvider +from src.llms.openai import OpenAIProvider +from src.llms.xai import XAIProvider + + +def test_get_llm_builds_xai_provider(): + provider = get_llm( + provider="xai", + model="grok-4-1-fast-reasoning", + image_provider="xai", + image_model="grok-imagine-image", + region="us-east-1", + api_keys={"xai": "xai-secret"}, + ) + assert isinstance(provider, XAIProvider) + assert provider._api_key == "xai-secret" + + +def test_get_llm_composite_xai_text_openai_image(): + """Mixed-provider setups still work through _CompositeProvider.""" + provider = get_llm( + provider="xai", + model="grok-4-1-fast-reasoning", + image_provider="openai", + image_model="gpt-image-1", + region="us-east-1", + api_keys={"xai": "xai-key"}, + ) + assert isinstance(provider, _CompositeProvider) + assert isinstance(provider.text, XAIProvider) + assert isinstance(provider.image, OpenAIProvider) diff --git a/tests/llms/test_openai.py b/tests/llms/test_openai.py new file mode 100644 index 0000000..47240a4 --- /dev/null +++ b/tests/llms/test_openai.py @@ -0,0 +1,276 @@ +"""Tests for src.llms.openai.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +from src.llms.openai import OpenAIProvider + + +def _openai_completion(content="", tool_calls=None, finish="stop"): + choice = MagicMock() + choice.finish_reason = finish + choice.message.content = content + choice.message.tool_calls = tool_calls or [] + completion = MagicMock() + completion.choices = [choice] + completion.usage.prompt_tokens = 10 + completion.usage.completion_tokens = 20 + return completion + + +def _openai_tool_call(call_id, name, args_obj): + tc = MagicMock() + tc.id = call_id + tc.function.name = name + tc.function.arguments = json.dumps(args_obj) + return tc + + +def _make_stream_chunk(*, content=None, tool_calls=None, finish=None, usage=None): + chunk = MagicMock() + if usage is not None: + chunk.usage = usage + else: + chunk.usage = None + if content is None and tool_calls is None and finish is None: + chunk.choices = [] + return chunk + choice = MagicMock() + choice.finish_reason = finish + choice.delta.content = content + choice.delta.tool_calls = tool_calls + chunk.choices = [choice] + return chunk + + +def _stream_tool_call(index, call_id=None, name=None, arguments=None): + tc = MagicMock() + tc.index = index + tc.id = call_id + fn = MagicMock() + fn.name = name + fn.arguments = arguments + tc.function = fn + return tc + + +def test_openai_chat_parses_text(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="hello") + result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) + assert result.content == "hello" + assert result.stop_reason == "end_turn" + assert result.tool_calls == [] + assert result.token_usage == {"input": 10, "output": 20} + + +def test_openai_legacy_model_uses_max_tokens_and_temperature(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_tokens" in kwargs + assert "temperature" in kwargs + assert "max_completion_tokens" not in kwargs + + +def test_openai_new_generation_uses_max_completion_tokens(): + provider = OpenAIProvider(model="gpt-5.4", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_completion_tokens" in kwargs + assert "max_tokens" not in kwargs + assert "temperature" not in kwargs + + +def test_openai_o1_model_uses_max_completion_tokens(): + provider = OpenAIProvider(model="o1-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="x") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_completion_tokens" in kwargs + assert "temperature" not in kwargs + + +def test_openai_chat_streams_content_when_on_delta_given(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + chunks = [ + _make_stream_chunk(content="hello"), + _make_stream_chunk(content=" world"), + _make_stream_chunk(finish="stop"), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + received: list[str] = [] + result = provider.chat(system="s", messages=[], on_delta=received.append) + + assert received == ["hello", " world"] + assert result.content == "hello world" + assert result.stop_reason == "end_turn" + assert result.tool_calls == [] + # must have requested streaming + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert kwargs.get("stream") is True + + +def test_openai_chat_stream_suppresses_content_after_tool_calls_begin(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + chunks = [ + # tool_call begins first (no content yet) + _make_stream_chunk(tool_calls=[_stream_tool_call(0, call_id="c1", name="search_web")]), + _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='{"query":')]), + _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='"x"}')]), + # Model sometimes also emits commentary content after initiating a tool_call. + _make_stream_chunk(content="I'll search."), + _make_stream_chunk(finish="tool_calls"), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + received: list[str] = [] + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + on_delta=received.append, + ) + + # Content after tool_calls started must NOT reach on_delta. + assert received == [] + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "x"} + assert result.stop_reason == "tool_use" + + +def test_openai_chat_stream_accumulates_usage_from_last_chunk(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + usage = MagicMock() + usage.prompt_tokens = 42 + usage.completion_tokens = 7 + chunks = [ + _make_stream_chunk(content="hi"), + _make_stream_chunk(finish="stop", usage=usage), + ] + provider._client.chat.completions.create.return_value = iter(chunks) + + result = provider.chat(system="s", messages=[], on_delta=lambda _: None) + assert result.token_usage == {"input": 42, "output": 7} + + +def test_openai_translates_canonical_tool_calls(): + """Canonical assistant tool_calls must be serialized to OpenAI's wire format.""" + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="done") + + canonical = [ + {"role": "user", "content": "ask"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "c1", "name": "search_web", "arguments": {"query": "q"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "{\"ok\": true}"}, + ] + provider.chat(system="s", messages=canonical) + sent = provider._client.chat.completions.create.call_args.kwargs["messages"] + # system + 3 canonical = 4 + assert len(sent) == 4 + assistant = sent[2] + assert assistant["role"] == "assistant" + assert assistant["tool_calls"][0]["type"] == "function" + assert assistant["tool_calls"][0]["function"]["name"] == "search_web" + # arguments must be a JSON string, not a dict + assert isinstance(assistant["tool_calls"][0]["function"]["arguments"], str) + import json as _json + + assert _json.loads(assistant["tool_calls"][0]["function"]["arguments"]) == {"query": "q"} + + +def test_openai_chat_parses_tool_calls(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + tc = _openai_tool_call("call_1", "search_web", {"query": "aws"}) + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) + assert result.stop_reason == "tool_use" + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "aws"} + + +def test_openai_chat_handles_bad_tool_arguments(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + tc = MagicMock() + tc.id = "x" + tc.function.name = "search_web" + tc.function.arguments = "not json" + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) + assert result.tool_calls[0].arguments == {} + + +def test_openai_stream_chat_invokes_callback(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + + def _chunk(text): + ch = MagicMock() + ch.choices[0].delta.content = text + return ch + + provider._client.chat.completions.create.return_value = iter([_chunk("he"), _chunk("llo")]) + seen = [] + result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) + assert result == "hello" + assert seen == ["he", "llo"] + + +def test_openai_describe_image_uses_vision_format(): + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="it's a cat") + out = provider.describe_image(b"\x89PNG", "image/png") + assert out == "it's a cat" + args = provider._client.chat.completions.create.call_args.kwargs + assert args["messages"][0]["content"][1]["type"] == "image_url" + + +def test_openai_generate_image_decodes_b64(): + import base64 as _b64 + + provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=_b64.b64encode(b"hello").decode())] + provider._client.images.generate.return_value = response + assert provider.generate_image("cat") == b"hello" + kwargs = provider._client.images.generate.call_args.kwargs + # gpt-image-1 must NOT send response_format (API rejects it) + assert "response_format" not in kwargs + + +def test_openai_generate_image_dalle_sends_response_format(): + import base64 as _b64 + + provider = OpenAIProvider(model="gpt-4o-mini", image_model="dall-e-3") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=_b64.b64encode(b"ok").decode())] + provider._client.images.generate.return_value = response + provider.generate_image("cat") + kwargs = provider._client.images.generate.call_args.kwargs + assert kwargs["response_format"] == "b64_json" diff --git a/tests/llms/test_openai_wire.py b/tests/llms/test_openai_wire.py new file mode 100644 index 0000000..b31f380 --- /dev/null +++ b/tests/llms/test_openai_wire.py @@ -0,0 +1,2 @@ +"""Tests for src.llms.openai_wire.""" +from __future__ import annotations diff --git a/tests/llms/test_xai.py b/tests/llms/test_xai.py new file mode 100644 index 0000000..7ec4c0a --- /dev/null +++ b/tests/llms/test_xai.py @@ -0,0 +1,111 @@ +"""Tests for src.llms.xai.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from src.llms.xai import XAIProvider + + +def _openai_completion(content="", tool_calls=None, finish="stop"): + choice = MagicMock() + choice.finish_reason = finish + choice.message.content = content + choice.message.tool_calls = tool_calls or [] + completion = MagicMock() + completion.choices = [choice] + completion.usage.prompt_tokens = 10 + completion.usage.completion_tokens = 20 + return completion + + +def _openai_tool_call(call_id, name, args_obj): + tc = MagicMock() + tc.id = call_id + tc.function.name = name + tc.function.arguments = json.dumps(args_obj) + return tc + + +def test_xai_provider_uses_xai_base_url_and_api_key(): + """XAIProvider must instantiate OpenAI client with the xAI base URL and + the explicit api_key, so traffic goes to api.x.ai rather than OpenAI.""" + provider = XAIProvider( + model="grok-4-1-fast-reasoning", + image_model="grok-imagine-image", + api_key="xai-test", + ) + with patch("openai.OpenAI") as openai_ctor: + openai_ctor.return_value = MagicMock() + provider._get_client() + kwargs = openai_ctor.call_args.kwargs + assert kwargs.get("base_url") == "https://api.x.ai/v1" + assert kwargs.get("api_key") == "xai-test" + + +def test_xai_chat_parses_tool_calls(): + """Grok returns the same wire shape as OpenAI for tool calls; the + shared parser must turn them into ToolCall objects.""" + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + tc = _openai_tool_call("call_g1", "search_web", {"query": "xai"}) + provider._client.chat.completions.create.return_value = _openai_completion( + tool_calls=[tc], finish="tool_calls" + ) + result = provider.chat( + system="s", + messages=[], + tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], + ) + assert result.stop_reason == "tool_use" + assert result.tool_calls[0].name == "search_web" + assert result.tool_calls[0].arguments == {"query": "xai"} + + +def test_xai_chat_uses_legacy_max_tokens_always(): + """All current grok chat models accept max_tokens + temperature; + XAIProvider must not switch to max_completion_tokens (OpenAI-only split).""" + provider = XAIProvider(model="grok-4.20-0309-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + provider._client.chat.completions.create.return_value = _openai_completion(content="hi") + provider.chat(system="s", messages=[]) + kwargs = provider._client.chat.completions.create.call_args.kwargs + assert "max_tokens" in kwargs + assert "temperature" in kwargs + assert "max_completion_tokens" not in kwargs + + +def test_xai_generate_image_skips_size_and_requests_b64(): + """xAI images.generate rejects `size` (uses aspect_ratio/resolution). + We must omit it and explicitly ask for b64_json so we can decode bytes + into files_upload_v2.""" + import base64 as _b64 + + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + response = MagicMock() + response.data = [MagicMock(b64_json=_b64.b64encode(b"xai-bytes").decode())] + provider._client.images.generate.return_value = response + + assert provider.generate_image("a cat") == b"xai-bytes" + kwargs = provider._client.images.generate.call_args.kwargs + assert kwargs["model"] == "grok-imagine-image" + assert kwargs["prompt"] == "a cat" + assert kwargs["response_format"] == "b64_json" + assert "size" not in kwargs # xAI rejects this + + +def test_xai_stream_chat_emits_deltas(): + provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") + provider._client = MagicMock() + + def _chunk(text): + ch = MagicMock() + ch.choices[0].delta.content = text + return ch + + provider._client.chat.completions.create.return_value = iter([_chunk("gr"), _chunk("ok")]) + seen: list[str] = [] + result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) + assert result == "grok" + assert seen == ["gr", "ok"] diff --git a/tests/test_llm.py b/tests/test_llm.py deleted file mode 100644 index 7dc4deb..0000000 --- a/tests/test_llm.py +++ /dev/null @@ -1,651 +0,0 @@ -import json -from unittest.mock import MagicMock, patch - -from src.llms import ToolCall, get_llm -from src.llms.bedrock import BedrockProvider -from src.llms.openai import OpenAIProvider -from src.llms.xai import XAIProvider - - -# --------------------------------------------------------------------------- # -# OpenAI -# --------------------------------------------------------------------------- # - - -def _openai_completion(content="", tool_calls=None, finish="stop"): - choice = MagicMock() - choice.finish_reason = finish - choice.message.content = content - choice.message.tool_calls = tool_calls or [] - completion = MagicMock() - completion.choices = [choice] - completion.usage.prompt_tokens = 10 - completion.usage.completion_tokens = 20 - return completion - - -def _openai_tool_call(call_id, name, args_obj): - tc = MagicMock() - tc.id = call_id - tc.function.name = name - tc.function.arguments = json.dumps(args_obj) - return tc - - -def test_openai_chat_parses_text(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="hello") - result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) - assert result.content == "hello" - assert result.stop_reason == "end_turn" - assert result.tool_calls == [] - assert result.token_usage == {"input": 10, "output": 20} - - -def test_openai_legacy_model_uses_max_tokens_and_temperature(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="x") - provider.chat(system="s", messages=[]) - kwargs = provider._client.chat.completions.create.call_args.kwargs - assert "max_tokens" in kwargs - assert "temperature" in kwargs - assert "max_completion_tokens" not in kwargs - - -def test_openai_new_generation_uses_max_completion_tokens(): - provider = OpenAIProvider(model="gpt-5.4", image_model="gpt-image-1") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="x") - provider.chat(system="s", messages=[]) - kwargs = provider._client.chat.completions.create.call_args.kwargs - assert "max_completion_tokens" in kwargs - assert "max_tokens" not in kwargs - assert "temperature" not in kwargs - - -def test_openai_o1_model_uses_max_completion_tokens(): - provider = OpenAIProvider(model="o1-mini", image_model="gpt-image-1") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="x") - provider.chat(system="s", messages=[]) - kwargs = provider._client.chat.completions.create.call_args.kwargs - assert "max_completion_tokens" in kwargs - assert "temperature" not in kwargs - - -def _make_stream_chunk(*, content=None, tool_calls=None, finish=None, usage=None): - chunk = MagicMock() - if usage is not None: - chunk.usage = usage - else: - chunk.usage = None - if content is None and tool_calls is None and finish is None: - chunk.choices = [] - return chunk - choice = MagicMock() - choice.finish_reason = finish - choice.delta.content = content - choice.delta.tool_calls = tool_calls - chunk.choices = [choice] - return chunk - - -def _stream_tool_call(index, call_id=None, name=None, arguments=None): - tc = MagicMock() - tc.index = index - tc.id = call_id - fn = MagicMock() - fn.name = name - fn.arguments = arguments - tc.function = fn - return tc - - -def test_openai_chat_streams_content_when_on_delta_given(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - chunks = [ - _make_stream_chunk(content="hello"), - _make_stream_chunk(content=" world"), - _make_stream_chunk(finish="stop"), - ] - provider._client.chat.completions.create.return_value = iter(chunks) - - received: list[str] = [] - result = provider.chat(system="s", messages=[], on_delta=received.append) - - assert received == ["hello", " world"] - assert result.content == "hello world" - assert result.stop_reason == "end_turn" - assert result.tool_calls == [] - # must have requested streaming - kwargs = provider._client.chat.completions.create.call_args.kwargs - assert kwargs.get("stream") is True - - -def test_openai_chat_stream_suppresses_content_after_tool_calls_begin(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - chunks = [ - # tool_call begins first (no content yet) - _make_stream_chunk(tool_calls=[_stream_tool_call(0, call_id="c1", name="search_web")]), - _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='{"query":')]), - _make_stream_chunk(tool_calls=[_stream_tool_call(0, arguments='"x"}')]), - # Model sometimes also emits commentary content after initiating a tool_call. - _make_stream_chunk(content="I'll search."), - _make_stream_chunk(finish="tool_calls"), - ] - provider._client.chat.completions.create.return_value = iter(chunks) - - received: list[str] = [] - result = provider.chat( - system="s", - messages=[], - tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], - on_delta=received.append, - ) - - # Content after tool_calls started must NOT reach on_delta. - assert received == [] - assert result.tool_calls[0].name == "search_web" - assert result.tool_calls[0].arguments == {"query": "x"} - assert result.stop_reason == "tool_use" - - -def test_openai_chat_stream_accumulates_usage_from_last_chunk(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - usage = MagicMock() - usage.prompt_tokens = 42 - usage.completion_tokens = 7 - chunks = [ - _make_stream_chunk(content="hi"), - _make_stream_chunk(finish="stop", usage=usage), - ] - provider._client.chat.completions.create.return_value = iter(chunks) - - result = provider.chat(system="s", messages=[], on_delta=lambda _: None) - assert result.token_usage == {"input": 42, "output": 7} - - -def test_openai_translates_canonical_tool_calls(): - """Canonical assistant tool_calls must be serialized to OpenAI's wire format.""" - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="done") - - canonical = [ - {"role": "user", "content": "ask"}, - { - "role": "assistant", - "content": "", - "tool_calls": [{"id": "c1", "name": "search_web", "arguments": {"query": "q"}}], - }, - {"role": "tool", "tool_call_id": "c1", "content": "{\"ok\": true}"}, - ] - provider.chat(system="s", messages=canonical) - sent = provider._client.chat.completions.create.call_args.kwargs["messages"] - # system + 3 canonical = 4 - assert len(sent) == 4 - assistant = sent[2] - assert assistant["role"] == "assistant" - assert assistant["tool_calls"][0]["type"] == "function" - assert assistant["tool_calls"][0]["function"]["name"] == "search_web" - # arguments must be a JSON string, not a dict - assert isinstance(assistant["tool_calls"][0]["function"]["arguments"], str) - import json as _json - - assert _json.loads(assistant["tool_calls"][0]["function"]["arguments"]) == {"query": "q"} - - -def test_openai_chat_parses_tool_calls(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - tc = _openai_tool_call("call_1", "search_web", {"query": "aws"}) - provider._client.chat.completions.create.return_value = _openai_completion( - tool_calls=[tc], finish="tool_calls" - ) - result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) - assert result.stop_reason == "tool_use" - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].name == "search_web" - assert result.tool_calls[0].arguments == {"query": "aws"} - - -def test_openai_chat_handles_bad_tool_arguments(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - tc = MagicMock() - tc.id = "x" - tc.function.name = "search_web" - tc.function.arguments = "not json" - provider._client.chat.completions.create.return_value = _openai_completion( - tool_calls=[tc], finish="tool_calls" - ) - result = provider.chat(system="s", messages=[], tools=[{"name": "search_web", "description": "", "parameters": {}}]) - assert result.tool_calls[0].arguments == {} - - -def test_openai_stream_chat_invokes_callback(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - - def _chunk(text): - ch = MagicMock() - ch.choices[0].delta.content = text - return ch - - provider._client.chat.completions.create.return_value = iter([_chunk("he"), _chunk("llo")]) - seen = [] - result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) - assert result == "hello" - assert seen == ["he", "llo"] - - -# --------------------------------------------------------------------------- # -# Bedrock — Claude -# --------------------------------------------------------------------------- # - - -def _bedrock_response(payload: dict): - body = MagicMock() - body.read.return_value = json.dumps(payload).encode() - return {"body": body} - - -def test_bedrock_inference_profile_routes_to_claude(): - """us.anthropic.claude-* inference profile IDs must still hit the Claude - path (tool_use, messages API), not the unknown-family fallback.""" - provider = BedrockProvider( - model="us.anthropic.claude-opus-4-6-v1", - image_model="amazon.nova-canvas-v1:0", - region="us-east-1", - ) - provider._client = MagicMock() - provider._client.invoke_model.return_value = _bedrock_response( - { - "content": [{"type": "text", "text": "hi"}], - "stop_reason": "end_turn", - "usage": {"input_tokens": 1, "output_tokens": 2}, - } - ) - tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] - result = provider.chat(system="s", messages=[], tools=tools) - assert result.content == "hi" - # Claude body carries tools (not Nova's toolConfig) - body = provider._client.invoke_model.call_args.kwargs["body"] - import json as _json - - parsed = _json.loads(body) - assert "tools" in parsed # routed into _claude_chat, not fallback - assert parsed["tools"][0]["name"] == "search_web" - - -def test_bedrock_inference_profile_image_routing(): - """global./us. prefixed image model IDs still reach the Titan/Nova body.""" - provider = BedrockProvider( - model="us.anthropic.claude-opus-4-6-v1", - image_model="us.amazon.nova-canvas-v1:0", - region="us-east-1", - ) - body = provider._build_image_body("cat") - assert body["taskType"] == "TEXT_IMAGE" - - -def test_bedrock_claude_chat_text(): - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="amazon.titan-image-generator-v1", - region="us-east-1", - ) - provider._client = MagicMock() - provider._client.invoke_model.return_value = _bedrock_response( - { - "content": [{"type": "text", "text": "안녕"}], - "stop_reason": "end_turn", - "usage": {"input_tokens": 5, "output_tokens": 7}, - } - ) - result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) - assert result.content == "안녕" - assert result.stop_reason == "end_turn" - assert result.token_usage == {"input": 5, "output": 7} - - -def test_bedrock_claude_chat_with_tool_use(): - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="amazon.titan-image-generator-v1", - region="us-east-1", - ) - provider._client = MagicMock() - provider._client.invoke_model.return_value = _bedrock_response( - { - "content": [ - {"type": "text", "text": "I'll search."}, - {"type": "tool_use", "id": "tu_1", "name": "search_web", "input": {"query": "x"}}, - ], - "stop_reason": "tool_use", - "usage": {"input_tokens": 3, "output_tokens": 4}, - } - ) - tools = [{"name": "search_web", "description": "", "parameters": {"type": "object"}}] - result = provider.chat(system="s", messages=[], tools=tools) - assert result.stop_reason == "tool_use" - assert result.tool_calls[0].name == "search_web" - assert result.tool_calls[0].arguments == {"query": "x"} - - -def test_bedrock_message_translation_tool_role(): - messages = [ - {"role": "user", "content": "ask"}, - {"role": "assistant", "content": "", "tool_calls": [{"id": "t1", "name": "foo", "arguments": {"a": 1}}]}, - {"role": "tool", "tool_call_id": "t1", "content": "{\"ok\":true}"}, - ] - translated = BedrockProvider._to_anthropic_messages(messages) - assert translated[0] == {"role": "user", "content": "ask"} - assert translated[1]["role"] == "assistant" - assert translated[1]["content"][0]["type"] == "tool_use" - assert translated[1]["content"][0]["name"] == "foo" - assert translated[2]["role"] == "user" - assert translated[2]["content"][0]["type"] == "tool_result" - - -# --------------------------------------------------------------------------- # -# Bedrock — Nova -# --------------------------------------------------------------------------- # - - -def test_bedrock_nova_chat_text(): - provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") - provider._client = MagicMock() - provider._client.converse.return_value = { - "output": {"message": {"content": [{"text": "hi"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 1, "outputTokens": 2}, - } - result = provider.chat(system="s", messages=[{"role": "user", "content": "hi"}]) - assert result.content == "hi" - assert result.stop_reason == "end_turn" - assert result.token_usage == {"input": 1, "output": 2} - - -def test_bedrock_nova_tool_use(): - provider = BedrockProvider(model="amazon.nova-pro-v1:0", image_model="amazon.nova-canvas-v1:0", region="us-east-1") - provider._client = MagicMock() - provider._client.converse.return_value = { - "output": { - "message": { - "content": [ - {"text": "let me search"}, - {"toolUse": {"toolUseId": "tu1", "name": "search_web", "input": {"query": "q"}}}, - ] - } - }, - "stopReason": "tool_use", - "usage": {"inputTokens": 2, "outputTokens": 3}, - } - result = provider.chat( - system="s", - messages=[], - tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], - ) - assert result.stop_reason == "tool_use" - assert result.tool_calls[0].name == "search_web" - - -# --------------------------------------------------------------------------- # -# Image model adapter -# --------------------------------------------------------------------------- # - - -def test_build_image_body_titan(): - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="amazon.titan-image-generator-v1", - region="us-east-1", - ) - body = provider._build_image_body("a cat") - assert body["taskType"] == "TEXT_IMAGE" - - -def test_build_image_body_stability(): - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="stability.stable-diffusion-xl-v1", - region="us-east-1", - ) - body = provider._build_image_body("a cat") - assert body["text_prompts"][0]["text"] == "a cat" - - -def test_build_image_body_unknown_raises(): - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="mystery.v1", - region="us-east-1", - ) - import pytest - - with pytest.raises(ValueError): - provider._build_image_body("x") - - -def test_bedrock_describe_image_returns_text(): - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="amazon.titan-image-generator-v1", - region="us-east-1", - ) - provider._client = MagicMock() - provider._client.invoke_model.return_value = _bedrock_response( - {"content": [{"type": "text", "text": "a cat"}]} - ) - out = provider.describe_image(b"fake", "image/png") - assert out == "a cat" - - -def test_bedrock_generate_image_titan_returns_bytes(): - import base64 as _b64 - - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="amazon.titan-image-generator-v1", - region="us-east-1", - ) - provider._client = MagicMock() - provider._client.invoke_model.return_value = _bedrock_response( - {"images": [_b64.b64encode(b"imgdata").decode()]} - ) - assert provider.generate_image("cat") == b"imgdata" - - -def test_bedrock_generate_image_stability_returns_bytes(): - import base64 as _b64 - - provider = BedrockProvider( - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - image_model="stability.stable-diffusion-xl-v1", - region="us-east-1", - ) - provider._client = MagicMock() - provider._client.invoke_model.return_value = _bedrock_response( - {"artifacts": [{"base64": _b64.b64encode(b"xyz").decode()}]} - ) - assert provider.generate_image("cat") == b"xyz" - - -def test_openai_describe_image_uses_vision_format(): - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="it's a cat") - out = provider.describe_image(b"\x89PNG", "image/png") - assert out == "it's a cat" - args = provider._client.chat.completions.create.call_args.kwargs - assert args["messages"][0]["content"][1]["type"] == "image_url" - - -def test_openai_generate_image_decodes_b64(): - import base64 as _b64 - - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") - provider._client = MagicMock() - response = MagicMock() - response.data = [MagicMock(b64_json=_b64.b64encode(b"hello").decode())] - provider._client.images.generate.return_value = response - assert provider.generate_image("cat") == b"hello" - kwargs = provider._client.images.generate.call_args.kwargs - # gpt-image-1 must NOT send response_format (API rejects it) - assert "response_format" not in kwargs - - -def test_openai_generate_image_dalle_sends_response_format(): - import base64 as _b64 - - provider = OpenAIProvider(model="gpt-4o-mini", image_model="dall-e-3") - provider._client = MagicMock() - response = MagicMock() - response.data = [MagicMock(b64_json=_b64.b64encode(b"ok").decode())] - provider._client.images.generate.return_value = response - provider.generate_image("cat") - kwargs = provider._client.images.generate.call_args.kwargs - assert kwargs["response_format"] == "b64_json" - - -def test_composite_provider_routes_image_to_image_llm(): - from src.llms.composite import _CompositeProvider - - text = MagicMock() - image = MagicMock() - image.generate_image.return_value = b"img" - composite = _CompositeProvider(text=text, image=image) - composite.chat(system="s", messages=[]) - text.chat.assert_called_once() - composite.generate_image("x") - image.generate_image.assert_called_once_with("x") - - -# --------------------------------------------------------------------------- # -# xAI (Grok) -# --------------------------------------------------------------------------- # - - -def test_xai_provider_uses_xai_base_url_and_api_key(): - """XAIProvider must instantiate OpenAI client with the xAI base URL and - the explicit api_key, so traffic goes to api.x.ai rather than OpenAI.""" - provider = XAIProvider( - model="grok-4-1-fast-reasoning", - image_model="grok-imagine-image", - api_key="xai-test", - ) - with patch("openai.OpenAI") as openai_ctor: - openai_ctor.return_value = MagicMock() - provider._get_client() - kwargs = openai_ctor.call_args.kwargs - assert kwargs.get("base_url") == "https://api.x.ai/v1" - assert kwargs.get("api_key") == "xai-test" - - -def test_xai_chat_parses_tool_calls(): - """Grok returns the same wire shape as OpenAI for tool calls; the - shared parser must turn them into ToolCall objects.""" - provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") - provider._client = MagicMock() - tc = _openai_tool_call("call_g1", "search_web", {"query": "xai"}) - provider._client.chat.completions.create.return_value = _openai_completion( - tool_calls=[tc], finish="tool_calls" - ) - result = provider.chat( - system="s", - messages=[], - tools=[{"name": "search_web", "description": "", "parameters": {"type": "object"}}], - ) - assert result.stop_reason == "tool_use" - assert result.tool_calls[0].name == "search_web" - assert result.tool_calls[0].arguments == {"query": "xai"} - - -def test_xai_chat_uses_legacy_max_tokens_always(): - """All current grok chat models accept max_tokens + temperature; - XAIProvider must not switch to max_completion_tokens (OpenAI-only split).""" - provider = XAIProvider(model="grok-4.20-0309-reasoning", image_model="grok-imagine-image", api_key="x") - provider._client = MagicMock() - provider._client.chat.completions.create.return_value = _openai_completion(content="hi") - provider.chat(system="s", messages=[]) - kwargs = provider._client.chat.completions.create.call_args.kwargs - assert "max_tokens" in kwargs - assert "temperature" in kwargs - assert "max_completion_tokens" not in kwargs - - -def test_xai_generate_image_skips_size_and_requests_b64(): - """xAI images.generate rejects `size` (uses aspect_ratio/resolution). - We must omit it and explicitly ask for b64_json so we can decode bytes - into files_upload_v2.""" - import base64 as _b64 - - provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") - provider._client = MagicMock() - response = MagicMock() - response.data = [MagicMock(b64_json=_b64.b64encode(b"xai-bytes").decode())] - provider._client.images.generate.return_value = response - - assert provider.generate_image("a cat") == b"xai-bytes" - kwargs = provider._client.images.generate.call_args.kwargs - assert kwargs["model"] == "grok-imagine-image" - assert kwargs["prompt"] == "a cat" - assert kwargs["response_format"] == "b64_json" - assert "size" not in kwargs # xAI rejects this - - -def test_xai_stream_chat_emits_deltas(): - provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") - provider._client = MagicMock() - - def _chunk(text): - ch = MagicMock() - ch.choices[0].delta.content = text - return ch - - provider._client.chat.completions.create.return_value = iter([_chunk("gr"), _chunk("ok")]) - seen: list[str] = [] - result = provider.stream_chat(system="s", messages=[], on_delta=seen.append) - assert result == "grok" - assert seen == ["gr", "ok"] - - -# --------------------------------------------------------------------------- # -# Factory -# --------------------------------------------------------------------------- # - - -def test_get_llm_builds_xai_provider(): - provider = get_llm( - provider="xai", - model="grok-4-1-fast-reasoning", - image_provider="xai", - image_model="grok-imagine-image", - region="us-east-1", - api_keys={"xai": "xai-secret"}, - ) - assert isinstance(provider, XAIProvider) - assert provider._api_key == "xai-secret" - - -def test_get_llm_composite_xai_text_openai_image(): - """Mixed-provider setups still work through _CompositeProvider.""" - from src.llms.composite import _CompositeProvider - - provider = get_llm( - provider="xai", - model="grok-4-1-fast-reasoning", - image_provider="openai", - image_model="gpt-image-1", - region="us-east-1", - api_keys={"xai": "xai-key"}, - ) - assert isinstance(provider, _CompositeProvider) - assert isinstance(provider.text, XAIProvider) - assert isinstance(provider.image, OpenAIProvider) From 16582460cd9b934c85e9e32dd55e2016b551aba8 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:41:05 +0900 Subject: [PATCH 29/65] refactor(tests): hoist inline json/base64 imports to module level --- tests/llms/test_bedrock.py | 13 ++++--------- tests/llms/test_openai.py | 13 ++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/tests/llms/test_bedrock.py b/tests/llms/test_bedrock.py index f3f4b70..62fa011 100644 --- a/tests/llms/test_bedrock.py +++ b/tests/llms/test_bedrock.py @@ -1,6 +1,7 @@ """Tests for src.llms.bedrock.""" from __future__ import annotations +import base64 import json from unittest.mock import MagicMock @@ -36,9 +37,7 @@ def test_bedrock_inference_profile_routes_to_claude(): assert result.content == "hi" # Claude body carries tools (not Nova's toolConfig) body = provider._client.invoke_model.call_args.kwargs["body"] - import json as _json - - parsed = _json.loads(body) + parsed = json.loads(body) assert "tools" in parsed # routed into _claude_chat, not fallback assert parsed["tools"][0]["name"] == "search_web" @@ -196,8 +195,6 @@ def test_bedrock_describe_image_returns_text(): def test_bedrock_generate_image_titan_returns_bytes(): - import base64 as _b64 - provider = BedrockProvider( model="anthropic.claude-3-5-sonnet-20240620-v1:0", image_model="amazon.titan-image-generator-v1", @@ -205,14 +202,12 @@ def test_bedrock_generate_image_titan_returns_bytes(): ) provider._client = MagicMock() provider._client.invoke_model.return_value = _bedrock_response( - {"images": [_b64.b64encode(b"imgdata").decode()]} + {"images": [base64.b64encode(b"imgdata").decode()]} ) assert provider.generate_image("cat") == b"imgdata" def test_bedrock_generate_image_stability_returns_bytes(): - import base64 as _b64 - provider = BedrockProvider( model="anthropic.claude-3-5-sonnet-20240620-v1:0", image_model="stability.stable-diffusion-xl-v1", @@ -220,6 +215,6 @@ def test_bedrock_generate_image_stability_returns_bytes(): ) provider._client = MagicMock() provider._client.invoke_model.return_value = _bedrock_response( - {"artifacts": [{"base64": _b64.b64encode(b"xyz").decode()}]} + {"artifacts": [{"base64": base64.b64encode(b"xyz").decode()}]} ) assert provider.generate_image("cat") == b"xyz" diff --git a/tests/llms/test_openai.py b/tests/llms/test_openai.py index 47240a4..a5b95c5 100644 --- a/tests/llms/test_openai.py +++ b/tests/llms/test_openai.py @@ -1,6 +1,7 @@ """Tests for src.llms.openai.""" from __future__ import annotations +import base64 import json from unittest.mock import MagicMock @@ -190,9 +191,7 @@ def test_openai_translates_canonical_tool_calls(): assert assistant["tool_calls"][0]["function"]["name"] == "search_web" # arguments must be a JSON string, not a dict assert isinstance(assistant["tool_calls"][0]["function"]["arguments"], str) - import json as _json - - assert _json.loads(assistant["tool_calls"][0]["function"]["arguments"]) == {"query": "q"} + assert json.loads(assistant["tool_calls"][0]["function"]["arguments"]) == {"query": "q"} def test_openai_chat_parses_tool_calls(): @@ -250,12 +249,10 @@ def test_openai_describe_image_uses_vision_format(): def test_openai_generate_image_decodes_b64(): - import base64 as _b64 - provider = OpenAIProvider(model="gpt-4o-mini", image_model="gpt-image-1") provider._client = MagicMock() response = MagicMock() - response.data = [MagicMock(b64_json=_b64.b64encode(b"hello").decode())] + response.data = [MagicMock(b64_json=base64.b64encode(b"hello").decode())] provider._client.images.generate.return_value = response assert provider.generate_image("cat") == b"hello" kwargs = provider._client.images.generate.call_args.kwargs @@ -264,12 +261,10 @@ def test_openai_generate_image_decodes_b64(): def test_openai_generate_image_dalle_sends_response_format(): - import base64 as _b64 - provider = OpenAIProvider(model="gpt-4o-mini", image_model="dall-e-3") provider._client = MagicMock() response = MagicMock() - response.data = [MagicMock(b64_json=_b64.b64encode(b"ok").decode())] + response.data = [MagicMock(b64_json=base64.b64encode(b"ok").decode())] provider._client.images.generate.return_value = response provider.generate_image("cat") kwargs = provider._client.images.generate.call_args.kwargs From c8a3ff9881d1f8b5e25a7f1fa63d2bcaaf8eaf22 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:41:51 +0900 Subject: [PATCH 30/65] refactor(tests): hoist inline base64 import in test_xai --- tests/llms/test_xai.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/llms/test_xai.py b/tests/llms/test_xai.py index 7ec4c0a..8716822 100644 --- a/tests/llms/test_xai.py +++ b/tests/llms/test_xai.py @@ -1,6 +1,7 @@ """Tests for src.llms.xai.""" from __future__ import annotations +import base64 import json from unittest.mock import MagicMock, patch @@ -79,12 +80,10 @@ def test_xai_generate_image_skips_size_and_requests_b64(): """xAI images.generate rejects `size` (uses aspect_ratio/resolution). We must omit it and explicitly ask for b64_json so we can decode bytes into files_upload_v2.""" - import base64 as _b64 - provider = XAIProvider(model="grok-4-1-fast-reasoning", image_model="grok-imagine-image", api_key="x") provider._client = MagicMock() response = MagicMock() - response.data = [MagicMock(b64_json=_b64.b64encode(b"xai-bytes").decode())] + response.data = [MagicMock(b64_json=base64.b64encode(b"xai-bytes").decode())] provider._client.images.generate.return_value = response assert provider.generate_image("a cat") == b"xai-bytes" From 8d23d518e0aafda38a69fd7c36b68ef343c1983b Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:48:40 +0900 Subject: [PATCH 31/65] refactor(tools): split src/tools.py and src/tools_web.py into src/tools package - Created src/tools/__init__.py with public re-exports and side-effect imports - Created src/tools/registry.py with ToolDef, ToolRegistry, tool, ToolContext, ToolExecutor, default_registry - Created src/tools/slack.py with read_attached_images, read_attached_document, fetch_thread_history - Created src/tools/search.py with search_web, _ddg_search, _tavily_search - Created src/tools/web.py merging fetch_webpage from tools.py + all SSRF/HTML/fetch helpers from tools_web.py - Created src/tools/image.py with generate_image - Created src/tools/time.py with get_current_time - Deleted src/tools.py and src/tools_web.py - Updated tests/test_tools.py import paths and patch targets to new submodule locations Co-Authored-By: Claude Sonnet 4.6 --- src/tools/__init__.py | 34 +++ src/tools/image.py | 29 +++ src/tools/registry.py | 131 ++++++++++ src/tools/search.py | 67 +++++ src/{tools.py => tools/slack.py} | 389 +---------------------------- src/tools/time.py | 46 ++++ src/{tools_web.py => tools/web.py} | 134 +++++++++- tests/test_tools.py | 169 +++++++------ 8 files changed, 524 insertions(+), 475 deletions(-) create mode 100644 src/tools/__init__.py create mode 100644 src/tools/image.py create mode 100644 src/tools/registry.py create mode 100644 src/tools/search.py rename src/{tools.py => tools/slack.py} (50%) create mode 100644 src/tools/time.py rename src/{tools_web.py => tools/web.py} (68%) diff --git a/src/tools/__init__.py b/src/tools/__init__.py new file mode 100644 index 0000000..5c07c04 --- /dev/null +++ b/src/tools/__init__.py @@ -0,0 +1,34 @@ +"""Tool package. + +Public API: the registry plumbing plus the shared default_registry. +Tool functions themselves register via side-effect imports of the +sibling submodules below — importing this package is enough for the +agent to see every built-in tool. +""" +from src.tools.registry import ( + ToolContext, + ToolDef, + ToolExecutor, + ToolRegistry, + default_registry, + tool, +) + +# Side-effect imports: each submodule uses @tool(default_registry, ...) on +# one or more functions. Importing them registers those tools. +from src.tools import ( # noqa: F401 (imported for side effects) + image, + search, + slack, + time, + web, +) + +__all__ = [ + "ToolContext", + "ToolDef", + "ToolExecutor", + "ToolRegistry", + "default_registry", + "tool", +] diff --git a/src/tools/image.py b/src/tools/image.py new file mode 100644 index 0000000..f26d63a --- /dev/null +++ b/src/tools/image.py @@ -0,0 +1,29 @@ +"""Image generation tool — calls the configured LLM provider and uploads +to the current Slack thread.""" +from __future__ import annotations + +from src.tools.registry import ToolContext, default_registry, tool + + +@tool( + default_registry, + name="generate_image", + description="Generate an image from a prompt and upload it to the Slack thread. Returns the permalink.", + parameters={ + "type": "object", + "properties": {"prompt": {"type": "string"}}, + "required": ["prompt"], + }, + timeout=75.0, # gpt-image-1 / titan / stability can take 30–60s +) +def generate_image(ctx: ToolContext, prompt: str) -> dict[str, str]: + image_bytes = ctx.llm.generate_image(prompt) + upload = ctx.slack_client.files_upload_v2( + channel=ctx.channel, + thread_ts=ctx.thread_ts, + title="Generated image", + filename="generated.png", + file=image_bytes, + ) + file_info = upload.get("file", {}) + return {"permalink": file_info.get("permalink", ""), "title": file_info.get("title", "generated.png")} diff --git a/src/tools/registry.py b/src/tools/registry.py new file mode 100644 index 0000000..8f9d516 --- /dev/null +++ b/src/tools/registry.py @@ -0,0 +1,131 @@ +"""Tool registry + executor. Tool functions live in sibling submodules +(slack.py, search.py, web.py, image.py, time.py) and register themselves +via the @tool decorator on import.""" +from __future__ import annotations + +import json +import logging +import time +import urllib.error +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout +from dataclasses import dataclass, field +from typing import Any, Callable + +from botocore.exceptions import BotoCoreError, ClientError +from slack_sdk.errors import SlackApiError + +from src.config import Settings +from src.llms import LLMProvider, ToolCall + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # + + +@dataclass +class ToolDef: + name: str + description: str + parameters: dict[str, Any] # JSON Schema + fn: Callable[..., Any] + timeout: float | None = None # None -> use executor default + + +@dataclass +class ToolRegistry: + _tools: dict[str, ToolDef] = field(default_factory=dict) + + def register(self, td: ToolDef) -> None: + self._tools[td.name] = td + + def names(self) -> list[str]: + return list(self._tools.keys()) + + def get(self, name: str) -> ToolDef | None: + return self._tools.get(name) + + def specs(self) -> list[dict[str, Any]]: + return [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in self._tools.values() + ] + + +def tool( + registry: ToolRegistry, + name: str, + description: str, + parameters: dict[str, Any], + timeout: float | None = None, +): + def decorator(fn: Callable[..., Any]): + registry.register( + ToolDef(name=name, description=description, parameters=parameters, fn=fn, timeout=timeout) + ) + return fn + + return decorator + + +# --------------------------------------------------------------------------- # +# Context +# --------------------------------------------------------------------------- # + + +@dataclass +class ToolContext: + slack_client: Any + channel: str + thread_ts: str + event: dict[str, Any] + settings: Settings + llm: LLMProvider + + +# --------------------------------------------------------------------------- # +# Executor +# --------------------------------------------------------------------------- # + + +class ToolExecutor: + def __init__(self, context: ToolContext, registry: ToolRegistry, timeout: float = 20.0): + self.context = context + self.registry = registry + self.timeout = timeout + self._pool = ThreadPoolExecutor(max_workers=2) + + def execute(self, call: ToolCall) -> dict[str, Any]: + td = self.registry.get(call.name) + started = time.monotonic() + if td is None: + return {"ok": False, "error": f"unknown tool: {call.name}"} + effective_timeout = td.timeout if td.timeout is not None else self.timeout + try: + future = self._pool.submit(td.fn, self.context, **(call.arguments or {})) + result = future.result(timeout=effective_timeout) + return {"ok": True, "result": result, "duration_ms": int((time.monotonic() - started) * 1000)} + except FuturesTimeout: + logger.warning("tool %s timed out after %.1fs", call.name, effective_timeout) + return {"ok": False, "error": f"tool '{call.name}' timed out after {effective_timeout}s"} + except ( + TypeError, + ValueError, + KeyError, + urllib.error.URLError, + json.JSONDecodeError, + SlackApiError, + BotoCoreError, + ClientError, + ) as exc: + logger.exception("tool %s failed", call.name) + return {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"} + + +# --------------------------------------------------------------------------- # +# Built-in tools +# --------------------------------------------------------------------------- # + +default_registry = ToolRegistry() diff --git a/src/tools/search.py b/src/tools/search.py new file mode 100644 index 0000000..4ccf489 --- /dev/null +++ b/src/tools/search.py @@ -0,0 +1,67 @@ +"""Web search tool (DuckDuckGo Instant Answer + optional Tavily).""" +from __future__ import annotations + +import json +import logging +import urllib.parse +import urllib.request + +from src.tools.registry import ToolContext, default_registry, tool + +logger = logging.getLogger(__name__) + +DUCKDUCKGO_HOST = "api.duckduckgo.com" +TAVILY_HOST = "api.tavily.com" + + +@tool( + default_registry, + name="search_web", + description="Search the public web for up-to-date information. Uses Tavily if TAVILY_API_KEY is set, otherwise DuckDuckGo Instant Answer.", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5}, + }, + "required": ["query"], + }, +) +def search_web(ctx: ToolContext, query: str, limit: int = 5) -> list[dict[str, str]]: + if ctx.settings.tavily_api_key: + return _tavily_search(ctx.settings.tavily_api_key, query, limit) + return _ddg_search(query, limit) + + +def _ddg_search(query: str, limit: int) -> list[dict[str, str]]: + params = urllib.parse.urlencode({"q": query, "format": "json", "no_redirect": 1, "no_html": 1}) + url = f"https://{DUCKDUCKGO_HOST}/?{params}" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != DUCKDUCKGO_HOST: + raise ValueError("invalid web search URL") + with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + results: list[dict[str, str]] = [] + if payload.get("AbstractURL"): + results.append({"title": payload.get("AbstractText", ""), "url": payload["AbstractURL"]}) + for item in payload.get("RelatedTopics", []): + if "Text" in item and "FirstURL" in item: + results.append({"title": item["Text"], "url": item["FirstURL"]}) + if len(results) >= limit: + break + return results[:limit] + + +def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]]: + url = f"https://{TAVILY_HOST}/search" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != TAVILY_HOST: + raise ValueError("invalid Tavily URL") + body = json.dumps({"api_key": api_key, "query": query, "max_results": limit}).encode() + req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + return [ + {"title": r.get("title", ""), "url": r.get("url", ""), "content": r.get("content", "")} + for r in payload.get("results", [])[:limit] + ] diff --git a/src/tools.py b/src/tools/slack.py similarity index 50% rename from src/tools.py rename to src/tools/slack.py index c364c82..480e512 100644 --- a/src/tools.py +++ b/src/tools/slack.py @@ -1,169 +1,26 @@ -"""Tool registry + 6 built-in tools with JSON Schema specs. - -Tools are declared once via the `@tool(...)` decorator. The same registry -produces JSON Schemas for LLM function calling AND the executor's dispatch -table. A per-call timeout guards against slow network I/O from one tool -blocking the whole agent loop. -""" +"""Slack-centric tools: read images/documents attached to the triggering +mention, fetch the current thread's history.""" from __future__ import annotations -import json import logging -import socket import time import urllib.error import urllib.parse import urllib.request -from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout -from dataclasses import dataclass, field from typing import Any, Callable -from botocore.exceptions import BotoCoreError, ClientError from slack_sdk.errors import SlackApiError -from src.config import Settings -from src.llms import LLMProvider, ToolCall from src.slack_helpers import user_name_cache - -# Re-export web-fetch helpers so callers (and tests that still import from -# src.tools) keep working while the web-fetch module lives in tools_web.py. -from src.tools_web import ( - _HtmlTextExtractor, - _JINA_HEADER_MAX_LINES, - _JINA_LINK_RE, - _NoRedirectHandler, - _PUBLIC_WEB_UA, - _WEB_FETCH_TIMEOUT, - _extract_markdown_links, - _filter_links, - _jina_fetch, - _parse_jina_response, - _raw_fetch, - _read_body_capped, - _validate_public_https_url, -) +from src.tools.registry import ToolContext, default_registry, tool logger = logging.getLogger(__name__) - SLACK_FILE_HOSTS = {"files.slack.com", "files-edge.slack.com", "files-pri.slack.com"} -DUCKDUCKGO_HOST = "api.duckduckgo.com" -TAVILY_HOST = "api.tavily.com" DOC_TEXT_PREFIX = "text/" DOC_PDF_MIME = "application/pdf" -# --------------------------------------------------------------------------- # -# Registry -# --------------------------------------------------------------------------- # - - -@dataclass -class ToolDef: - name: str - description: str - parameters: dict[str, Any] # JSON Schema - fn: Callable[..., Any] - timeout: float | None = None # None -> use executor default - - -@dataclass -class ToolRegistry: - _tools: dict[str, ToolDef] = field(default_factory=dict) - - def register(self, td: ToolDef) -> None: - self._tools[td.name] = td - - def names(self) -> list[str]: - return list(self._tools.keys()) - - def get(self, name: str) -> ToolDef | None: - return self._tools.get(name) - - def specs(self) -> list[dict[str, Any]]: - return [ - {"name": t.name, "description": t.description, "parameters": t.parameters} - for t in self._tools.values() - ] - - -def tool( - registry: ToolRegistry, - name: str, - description: str, - parameters: dict[str, Any], - timeout: float | None = None, -): - def decorator(fn: Callable[..., Any]): - registry.register( - ToolDef(name=name, description=description, parameters=parameters, fn=fn, timeout=timeout) - ) - return fn - - return decorator - - -# --------------------------------------------------------------------------- # -# Context -# --------------------------------------------------------------------------- # - - -@dataclass -class ToolContext: - slack_client: Any - channel: str - thread_ts: str - event: dict[str, Any] - settings: Settings - llm: LLMProvider - - -# --------------------------------------------------------------------------- # -# Executor -# --------------------------------------------------------------------------- # - - -class ToolExecutor: - def __init__(self, context: ToolContext, registry: ToolRegistry, timeout: float = 20.0): - self.context = context - self.registry = registry - self.timeout = timeout - self._pool = ThreadPoolExecutor(max_workers=2) - - def execute(self, call: ToolCall) -> dict[str, Any]: - td = self.registry.get(call.name) - started = time.monotonic() - if td is None: - return {"ok": False, "error": f"unknown tool: {call.name}"} - effective_timeout = td.timeout if td.timeout is not None else self.timeout - try: - future = self._pool.submit(td.fn, self.context, **(call.arguments or {})) - result = future.result(timeout=effective_timeout) - return {"ok": True, "result": result, "duration_ms": int((time.monotonic() - started) * 1000)} - except FuturesTimeout: - logger.warning("tool %s timed out after %.1fs", call.name, effective_timeout) - return {"ok": False, "error": f"tool '{call.name}' timed out after {effective_timeout}s"} - except ( - TypeError, - ValueError, - KeyError, - urllib.error.URLError, - json.JSONDecodeError, - SlackApiError, - BotoCoreError, - ClientError, - ) as exc: - logger.exception("tool %s failed", call.name) - return {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"} - - -# --------------------------------------------------------------------------- # -# Built-in tools -# --------------------------------------------------------------------------- # - -default_registry = ToolRegistry() - - @tool( default_registry, name="read_attached_images", @@ -494,209 +351,6 @@ def _map(res: dict[str, Any]) -> list[dict[str, Any]]: ) -@tool( - default_registry, - name="search_web", - description="Search the public web for up-to-date information. Uses Tavily if TAVILY_API_KEY is set, otherwise DuckDuckGo Instant Answer.", - parameters={ - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5}, - }, - "required": ["query"], - }, -) -def search_web(ctx: ToolContext, query: str, limit: int = 5) -> list[dict[str, str]]: - if ctx.settings.tavily_api_key: - return _tavily_search(ctx.settings.tavily_api_key, query, limit) - return _ddg_search(query, limit) - - -@tool( - default_registry, - name="fetch_webpage", - description=( - "Fetch a public HTTPS web page and return clean text content plus a " - "list of outbound links. Use this for summarizing articles, landing " - "pages, or news indexes. For Slack-hosted files use " - "read_attached_document / read_attached_images instead." - ), - parameters={ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Absolute https URL of the page to fetch.", - }, - "max_chars": { - "type": "integer", - "minimum": 500, - "maximum": 20000, - "description": "Optional cap on returned content characters. Clamped by MAX_WEB_CHARS.", - }, - "max_links": { - "type": "integer", - "minimum": 0, - "maximum": 50, - "description": "Optional cap on returned link count. Clamped by MAX_WEB_LINKS.", - }, - }, - "required": ["url"], - }, - timeout=30.0, -) -def fetch_webpage( - ctx: ToolContext, - url: str, - max_chars: int | None = None, - max_links: int | None = None, -) -> dict[str, Any]: - _validate_public_https_url(url) - settings = ctx.settings - effective_chars = min( - max_chars if max_chars is not None else settings.max_web_chars, - settings.max_web_chars, - ) - effective_links = min( - max_links if max_links is not None else settings.max_web_links, - settings.max_web_links, - ) - - title = "" - content = "" - links: list[dict[str, str]] = [] - jina_ok = False - jina_err: str | None = None - - # HTTPError is a subclass of URLError; we list both to make the - # "HTTP status error" path visible as a first-class error type for - # readers. Order doesn't affect matching (URLError catches both). - try: - jina_text = _jina_fetch(settings.jina_reader_base, url, settings.max_web_bytes) - parsed_title, body = _parse_jina_response(jina_text) - if body.strip(): - title = parsed_title - content = body - links = _extract_markdown_links(body, url, effective_links) - jina_ok = True - else: - jina_err = "empty body" - except ( - ValueError, - urllib.error.HTTPError, - urllib.error.URLError, - socket.timeout, - UnicodeDecodeError, - ) as exc: - jina_err = f"{exc.__class__.__name__}: {exc}" - logger.debug("fetch_webpage jina fallback (url=%s): %s", url, jina_err) - - if not jina_ok: - # Belt-and-suspenders: re-validate before the raw fetch. _raw_fetch - # assumes its caller has passed the URL through - # _validate_public_https_url; re-checking here keeps that contract - # enforced even if future refactors mutate `url` in between. - _validate_public_https_url(url) - try: - html_text = _raw_fetch(url, settings.max_web_bytes) - except ( - ValueError, - urllib.error.HTTPError, - urllib.error.URLError, - socket.timeout, - ) as exc: - raise ValueError( - f"fetch_webpage failed: jina={jina_err}, raw={exc.__class__.__name__}: {exc}" - ) from exc - extractor = _HtmlTextExtractor(url) - extractor.feed(html_text) - title = extractor.title() or title - content = extractor.text() - links = _filter_links(extractor.links, url, effective_links) - - source = "jina" if jina_ok else "raw" - - truncated = False - if len(content) > effective_chars: - content = content[:effective_chars] - truncated = True - - return { - "url": url, - "title": title, - "content": content, - "links": links, - "chars": len(content), - "truncated": truncated, - "source": source, - } - - -@tool( - default_registry, - name="generate_image", - description="Generate an image from a prompt and upload it to the Slack thread. Returns the permalink.", - parameters={ - "type": "object", - "properties": {"prompt": {"type": "string"}}, - "required": ["prompt"], - }, - timeout=75.0, # gpt-image-1 / titan / stability can take 30–60s -) -def generate_image(ctx: ToolContext, prompt: str) -> dict[str, str]: - image_bytes = ctx.llm.generate_image(prompt) - upload = ctx.slack_client.files_upload_v2( - channel=ctx.channel, - thread_ts=ctx.thread_ts, - title="Generated image", - filename="generated.png", - file=image_bytes, - ) - file_info = upload.get("file", {}) - return {"permalink": file_info.get("permalink", ""), "title": file_info.get("title", "generated.png")} - - -@tool( - default_registry, - name="get_current_time", - description=( - "Return the current wall-clock time. Uses the server default " - "timezone (DEFAULT_TIMEZONE env) unless 'timezone' is provided. " - "Useful for 'today', 'now', 'this week', or weekday questions." - ), - parameters={ - "type": "object", - "properties": { - "timezone": { - "type": "string", - "description": ( - "Optional IANA timezone (e.g. 'Asia/Seoul', 'UTC', " - "'America/New_York'). Omit to use the server default." - ), - } - }, - "required": [], - }, -) -def get_current_time(ctx: ToolContext, timezone: str | None = None) -> dict[str, Any]: - from datetime import datetime - from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - - tz_name = timezone or ctx.settings.default_timezone - try: - tz = ZoneInfo(tz_name) - except ZoneInfoNotFoundError as exc: - raise ValueError(f"unknown timezone: {tz_name}") from exc - now = datetime.now(tz) - return { - "iso": now.isoformat(timespec="seconds"), - "timezone": tz_name, - "weekday": now.strftime("%A"), - "unix": int(now.timestamp()), - } - - # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # @@ -721,40 +375,3 @@ def _with_slack_retry(call: Callable[[], Any], map_result: Callable[[Any], Any], if last: raise last return [] - - -def _ddg_search(query: str, limit: int) -> list[dict[str, str]]: - params = urllib.parse.urlencode({"q": query, "format": "json", "no_redirect": 1, "no_html": 1}) - url = f"https://{DUCKDUCKGO_HOST}/?{params}" - parsed = urllib.parse.urlparse(url) - if parsed.scheme != "https" or parsed.hostname != DUCKDUCKGO_HOST: - raise ValueError("invalid web search URL") - with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - payload = json.loads(response.read().decode("utf-8")) - results: list[dict[str, str]] = [] - if payload.get("AbstractURL"): - results.append({"title": payload.get("AbstractText", ""), "url": payload["AbstractURL"]}) - for item in payload.get("RelatedTopics", []): - if "Text" in item and "FirstURL" in item: - results.append({"title": item["Text"], "url": item["FirstURL"]}) - if len(results) >= limit: - break - return results[:limit] - - -def _tavily_search(api_key: str, query: str, limit: int) -> list[dict[str, str]]: - url = f"https://{TAVILY_HOST}/search" - parsed = urllib.parse.urlparse(url) - if parsed.scheme != "https" or parsed.hostname != TAVILY_HOST: - raise ValueError("invalid Tavily URL") - body = json.dumps({"api_key": api_key, "query": query, "max_results": limit}).encode() - req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") - with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 - payload = json.loads(response.read().decode("utf-8")) - return [ - {"title": r.get("title", ""), "url": r.get("url", ""), "content": r.get("content", "")} - for r in payload.get("results", [])[:limit] - ] - - - diff --git a/src/tools/time.py b/src/tools/time.py new file mode 100644 index 0000000..a48de52 --- /dev/null +++ b/src/tools/time.py @@ -0,0 +1,46 @@ +"""Current-time tool for answering 'today', 'now', weekday questions.""" +from __future__ import annotations + +from typing import Any + +from src.tools.registry import ToolContext, default_registry, tool + + +@tool( + default_registry, + name="get_current_time", + description=( + "Return the current wall-clock time. Uses the server default " + "timezone (DEFAULT_TIMEZONE env) unless 'timezone' is provided. " + "Useful for 'today', 'now', 'this week', or weekday questions." + ), + parameters={ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": ( + "Optional IANA timezone (e.g. 'Asia/Seoul', 'UTC', " + "'America/New_York'). Omit to use the server default." + ), + } + }, + "required": [], + }, +) +def get_current_time(ctx: ToolContext, timezone: str | None = None) -> dict[str, Any]: + from datetime import datetime + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + tz_name = timezone or ctx.settings.default_timezone + try: + tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError as exc: + raise ValueError(f"unknown timezone: {tz_name}") from exc + now = datetime.now(tz) + return { + "iso": now.isoformat(timespec="seconds"), + "timezone": tz_name, + "weekday": now.strftime("%A"), + "unix": int(now.timestamp()), + } diff --git a/src/tools_web.py b/src/tools/web.py similarity index 68% rename from src/tools_web.py rename to src/tools/web.py index 4c20434..a6c5c9b 100644 --- a/src/tools_web.py +++ b/src/tools/web.py @@ -1,18 +1,24 @@ -"""Web-fetch helpers used by the fetch_webpage tool. +"""Public-HTTPS web fetch (Jina Reader with raw-urllib fallback) + SSRF guard. -Split out from src/tools.py to keep each module focused. Symbols defined -here are re-exported from src.tools for backward compatibility with test -imports and callers. +All helpers live here so the SSRF pre-flight, the no-redirect handler, the +Jina/raw fetchers, and the HTML/markdown parsers can be audited together. +Test patches target this module's urlopen / build_opener / getaddrinfo names. """ from __future__ import annotations import ipaddress +import logging import re import socket import urllib.error import urllib.parse import urllib.request from html.parser import HTMLParser +from typing import Any + +from src.tools.registry import ToolContext, default_registry, tool + +logger = logging.getLogger(__name__) _PUBLIC_WEB_UA = "lambda-gurumi-bot/1.0 (+https://github.com/nalbam/lambda-gurumi-bot)" @@ -268,3 +274,123 @@ def _parse_jina_response(text: str) -> tuple[str, str]: return title, text body = "\n".join(all_lines[body_start:]).lstrip("\n") return title, body + + +@tool( + default_registry, + name="fetch_webpage", + description=( + "Fetch a public HTTPS web page and return clean text content plus a " + "list of outbound links. Use this for summarizing articles, landing " + "pages, or news indexes. For Slack-hosted files use " + "read_attached_document / read_attached_images instead." + ), + parameters={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Absolute https URL of the page to fetch.", + }, + "max_chars": { + "type": "integer", + "minimum": 500, + "maximum": 20000, + "description": "Optional cap on returned content characters. Clamped by MAX_WEB_CHARS.", + }, + "max_links": { + "type": "integer", + "minimum": 0, + "maximum": 50, + "description": "Optional cap on returned link count. Clamped by MAX_WEB_LINKS.", + }, + }, + "required": ["url"], + }, + timeout=30.0, +) +def fetch_webpage( + ctx: ToolContext, + url: str, + max_chars: int | None = None, + max_links: int | None = None, +) -> dict[str, Any]: + _validate_public_https_url(url) + settings = ctx.settings + effective_chars = min( + max_chars if max_chars is not None else settings.max_web_chars, + settings.max_web_chars, + ) + effective_links = min( + max_links if max_links is not None else settings.max_web_links, + settings.max_web_links, + ) + + title = "" + content = "" + links: list[dict[str, str]] = [] + jina_ok = False + jina_err: str | None = None + + # HTTPError is a subclass of URLError; we list both to make the + # "HTTP status error" path visible as a first-class error type for + # readers. Order doesn't affect matching (URLError catches both). + try: + jina_text = _jina_fetch(settings.jina_reader_base, url, settings.max_web_bytes) + parsed_title, body = _parse_jina_response(jina_text) + if body.strip(): + title = parsed_title + content = body + links = _extract_markdown_links(body, url, effective_links) + jina_ok = True + else: + jina_err = "empty body" + except ( + ValueError, + urllib.error.HTTPError, + urllib.error.URLError, + socket.timeout, + UnicodeDecodeError, + ) as exc: + jina_err = f"{exc.__class__.__name__}: {exc}" + logger.debug("fetch_webpage jina fallback (url=%s): %s", url, jina_err) + + if not jina_ok: + # Belt-and-suspenders: re-validate before the raw fetch. _raw_fetch + # assumes its caller has passed the URL through + # _validate_public_https_url; re-checking here keeps that contract + # enforced even if future refactors mutate `url` in between. + _validate_public_https_url(url) + try: + html_text = _raw_fetch(url, settings.max_web_bytes) + except ( + ValueError, + urllib.error.HTTPError, + urllib.error.URLError, + socket.timeout, + ) as exc: + raise ValueError( + f"fetch_webpage failed: jina={jina_err}, raw={exc.__class__.__name__}: {exc}" + ) from exc + extractor = _HtmlTextExtractor(url) + extractor.feed(html_text) + title = extractor.title() or title + content = extractor.text() + links = _filter_links(extractor.links, url, effective_links) + + source = "jina" if jina_ok else "raw" + + truncated = False + if len(content) > effective_chars: + content = content[:effective_chars] + truncated = True + + return { + "url": url, + "title": title, + "content": content, + "links": links, + "chars": len(content), + "truncated": truncated, + "source": source, + } diff --git a/tests/test_tools.py b/tests/test_tools.py index 1d9db43..9d58b94 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -10,11 +10,10 @@ ToolExecutor, ToolRegistry, default_registry, - fetch_thread_history, - generate_image, - read_attached_images, - search_web, ) +from src.tools.slack import fetch_thread_history, read_attached_images +from src.tools.image import generate_image +from src.tools.search import search_web def _settings(**overrides) -> Settings: @@ -90,7 +89,7 @@ def test_executor_timeout_guards_slow_tools(): def slow(ctx): time.sleep(1.0) - from src.tools import ToolDef + from src.tools.registry import ToolDef registry.register(ToolDef(name="slow", description="", parameters={"type": "object", "properties": {}}, fn=slow)) executor = ToolExecutor(_ctx(), registry, timeout=0.1) @@ -113,7 +112,7 @@ def failing_bedrock(ctx): "InvokeModel", ) - from src.tools import ToolDef + from src.tools.registry import ToolDef registry.register( ToolDef( @@ -135,7 +134,7 @@ def test_executor_captures_tool_error(): def boom(ctx): raise ValueError("nope") - from src.tools import ToolDef + from src.tools.registry import ToolDef registry.register(ToolDef(name="boom", description="", parameters={"type": "object", "properties": {}}, fn=boom)) executor = ToolExecutor(_ctx(), registry) @@ -154,7 +153,7 @@ def moderately_slow(ctx): time.sleep(0.3) return "done" - from src.tools import ToolDef + from src.tools.registry import ToolDef registry.register( ToolDef( @@ -207,7 +206,7 @@ def test_read_attached_images_accepts_slack_host_variants(): llm = MagicMock() llm.describe_image.return_value = "a cat" ctx = _ctx(event=event, llm=llm) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: opener.return_value.__enter__.return_value.read.return_value = b"fake" result = read_attached_images(ctx, limit=1) assert result == [{"name": "a", "summary": "a cat"}] @@ -293,7 +292,7 @@ def test_read_attached_images_accepts_extra_urls(): be loadable via read_attached_images(urls=[...]).""" ctx = _ctx() ctx.llm.describe_image.return_value = "a cat history" - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: opener.return_value.__enter__.return_value.read.return_value = b"fake-bytes" out = read_attached_images( ctx, @@ -321,7 +320,7 @@ def test_read_attached_images_respects_total_limit_across_event_and_urls(): } ctx = _ctx(event=event) ctx.llm.describe_image.return_value = "desc" - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: opener.return_value.__enter__.return_value.read.return_value = b"x" out = read_attached_images( ctx, @@ -347,7 +346,7 @@ def test_search_web_ddg_parses_results(): "AbstractText": "abstract", "RelatedTopics": [{"Text": "t1", "FirstURL": "https://example.com/1"}], } - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.search.urllib.request.urlopen") as opener: opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() results = search_web(ctx, query="q", limit=5) assert results[0]["url"] == "https://example.com/a" @@ -364,7 +363,7 @@ def test_search_web_uses_tavily_when_key_set(): llm=MagicMock(), ) payload = {"results": [{"title": "t", "url": "https://x", "content": "c"}]} - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.search.urllib.request.urlopen") as opener: opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() out = search_web(ctx, query="q", limit=5) assert out == [{"title": "t", "url": "https://x", "content": "c"}] @@ -392,7 +391,7 @@ def test_generate_image_returns_permalink(): def test_get_current_time_uses_default_timezone(): - from src.tools import get_current_time + from src.tools.time import get_current_time ctx = _ctx() # _settings() default_timezone defaults to Asia/Seoul out = get_current_time(ctx) @@ -407,7 +406,7 @@ def test_get_current_time_uses_default_timezone(): def test_get_current_time_respects_custom_timezone(): - from src.tools import get_current_time + from src.tools.time import get_current_time ctx = _ctx() out = get_current_time(ctx, timezone="UTC") @@ -434,7 +433,7 @@ def test_get_current_time_invalid_tz_via_executor(): def test_read_attached_document_text_file(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document event = { "files": [ @@ -447,7 +446,7 @@ def test_read_attached_document_text_file(): } ctx = _ctx(event=event) body = b"Hello\n world.\nLine 3." - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.read.return_value = body resp.headers = {"Content-Length": str(len(body))} @@ -496,7 +495,7 @@ def _chunked(n=-1): def test_read_attached_document_pdf_happy_path(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document pdf = _build_pdf_bytes(["Hello PDF page one.", "Page two here."]) event = { @@ -509,7 +508,7 @@ def test_read_attached_document_pdf_happy_path(): ] } ctx = _ctx(event=event) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: _mock_pdf_response(opener, pdf) out = read_attached_document(ctx, limit=1) assert len(out) == 1 @@ -521,7 +520,7 @@ def test_read_attached_document_pdf_happy_path(): def test_read_attached_document_pdf_truncation(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document pdf = _build_pdf_bytes(["A" * 500]) event = { @@ -544,7 +543,7 @@ def test_read_attached_document_pdf_truncation(): settings=_settings(max_doc_chars=50), llm=ctx.llm, ) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: _mock_pdf_response(opener, pdf) out = read_attached_document(ctx, limit=1) assert out[0]["truncated"] is True @@ -552,7 +551,7 @@ def test_read_attached_document_pdf_truncation(): def test_read_attached_document_page_cap(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document pdf = _build_pdf_bytes(["p1", "p2", "p3"]) event = { @@ -572,7 +571,7 @@ def test_read_attached_document_page_cap(): settings=_settings(max_doc_pages=2), llm=MagicMock(), ) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: _mock_pdf_response(opener, pdf) out = read_attached_document(ctx, limit=1) assert "error" in out[0] @@ -580,7 +579,7 @@ def test_read_attached_document_page_cap(): def test_read_attached_document_size_cap_via_content_length(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document event = { "files": [ @@ -599,7 +598,7 @@ def test_read_attached_document_size_cap_via_content_length(): settings=_settings(max_doc_bytes=100), # tiny cap llm=MagicMock(), ) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": "200"} # > cap resp.read.return_value = b"x" * 10 # should never be read past cap @@ -609,7 +608,7 @@ def test_read_attached_document_size_cap_via_content_length(): def test_read_attached_document_size_cap_via_streamed_read(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document event = { "files": [ @@ -629,7 +628,7 @@ def test_read_attached_document_size_cap_via_streamed_read(): llm=MagicMock(), ) body = b"y" * 200 - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {} # no Content-Length buf = {"pos": 0} @@ -650,7 +649,7 @@ def _chunked(n=-1): def test_read_attached_document_rejects_non_slack_host(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document ctx = _ctx() out = read_attached_document( @@ -662,7 +661,7 @@ def test_read_attached_document_rejects_non_slack_host(): def test_read_attached_document_skips_encrypted_pdf(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document from io import BytesIO from pypdf import PdfWriter @@ -685,7 +684,7 @@ def test_read_attached_document_skips_encrypted_pdf(): ] } ctx = _ctx(event=event) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: _mock_pdf_response(opener, encrypted_pdf) out = read_attached_document(ctx, limit=1) assert "error" in out[0] @@ -693,7 +692,7 @@ def test_read_attached_document_skips_encrypted_pdf(): def test_read_attached_document_skips_image_mime(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document event = { "files": [ @@ -706,14 +705,14 @@ def test_read_attached_document_skips_image_mime(): } ctx = _ctx(event=event) # urlopen should NOT be called — image MIMEs are filtered before fetch - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: out = read_attached_document(ctx, limit=1) opener.assert_not_called() assert out == [] def test_read_attached_document_http_error_returns_per_item(): - from src.tools import read_attached_document + from src.tools.slack import read_attached_document import urllib.error event = { @@ -726,7 +725,7 @@ def test_read_attached_document_http_error_returns_per_item(): ] } ctx = _ctx(event=event) - with patch("src.tools.urllib.request.urlopen") as opener: + with patch("src.tools.slack.urllib.request.urlopen") as opener: opener.side_effect = urllib.error.HTTPError( url="https://files.slack.com/missing.pdf", code=404, @@ -746,56 +745,56 @@ def test_read_attached_document_http_error_returns_per_item(): def test_validate_public_https_url_rejects_http_scheme(): - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url with pytest.raises(ValueError, match="https"): _validate_public_https_url("http://example.com/") def test_validate_public_https_url_rejects_ip_literal_v4(): - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url with pytest.raises(ValueError, match="IP literals"): _validate_public_https_url("https://127.0.0.1/") def test_validate_public_https_url_rejects_ip_literal_v6(): - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url with pytest.raises(ValueError, match="IP literals"): _validate_public_https_url("https://[::1]/") def test_validate_public_https_url_rejects_private_dns(monkeypatch): - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): # Simulate DNS pointing at RFC1918 space. return [(None, None, None, "", ("10.0.0.1", port))] - monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) with pytest.raises(ValueError, match="non-public"): _validate_public_https_url("https://internal.corp.example/") def test_validate_public_https_url_rejects_metadata_host(monkeypatch): - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("169.254.169.254", port))] - monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) with pytest.raises(ValueError, match="non-public"): _validate_public_https_url("https://cloud.metadata.example/") def test_validate_public_https_url_accepts_public_host(monkeypatch): - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("93.184.216.34", port))] # example.com - monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) scheme, host = _validate_public_https_url("https://example.com/path") assert scheme == "https" assert host == "example.com" @@ -804,12 +803,12 @@ def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): def test_validate_public_https_url_dns_failure(monkeypatch): import socket as _socket - from src.tools import _validate_public_https_url + from src.tools.web import _validate_public_https_url def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): raise _socket.gaierror("nodename nor servname provided") - monkeypatch.setattr("src.tools_web.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) with pytest.raises(ValueError, match="DNS resolution failed"): _validate_public_https_url("https://nonexistent.invalid.example/") @@ -818,7 +817,7 @@ def test_no_redirect_handler_raises_on_302(): import urllib.error import urllib.request - from src.tools import _NoRedirectHandler + from src.tools.web import _NoRedirectHandler handler = _NoRedirectHandler() req = urllib.request.Request("https://example.com/") @@ -832,7 +831,7 @@ def test_no_redirect_handler_raises_on_302(): def test_html_text_extractor_basic(): - from src.tools import _HtmlTextExtractor + from src.tools.web import _HtmlTextExtractor html = ( " Hello " @@ -865,7 +864,7 @@ def test_html_text_extractor_basic(): def test_filter_links_drops_non_https_and_dedups(): - from src.tools import _filter_links + from src.tools.web import _filter_links raw = [ ("A", "https://a.example/1"), @@ -883,7 +882,7 @@ def test_filter_links_drops_non_https_and_dedups(): def test_filter_links_respects_limit(): - from src.tools import _filter_links + from src.tools.web import _filter_links raw = [(f"T{i}", f"https://x.example/{i}") for i in range(5)] out = _filter_links(raw, base_url="https://base.example/", limit=3) @@ -896,7 +895,7 @@ def test_filter_links_respects_limit(): def test_extract_markdown_links_parses_inline_markdown(): - from src.tools import _extract_markdown_links + from src.tools.web import _extract_markdown_links md = ( "Title\n\nSome prose with [Google](https://google.com/about) " @@ -911,7 +910,7 @@ def test_extract_markdown_links_parses_inline_markdown(): def test_parse_jina_response_strips_header(): - from src.tools import _parse_jina_response + from src.tools.web import _parse_jina_response payload = ( "Title: Example Page\n" @@ -926,7 +925,7 @@ def test_parse_jina_response_strips_header(): def test_parse_jina_response_no_header(): - from src.tools import _parse_jina_response + from src.tools.web import _parse_jina_response payload = "just raw markdown\n\nwith no prefix" title, body = _parse_jina_response(payload) @@ -935,7 +934,7 @@ def test_parse_jina_response_no_header(): def test_html_text_extractor_empty(): - from src.tools import _HtmlTextExtractor + from src.tools.web import _HtmlTextExtractor x = _HtmlTextExtractor("https://base.example/") x.feed("") @@ -945,7 +944,7 @@ def test_html_text_extractor_empty(): def test_filter_links_host_case_normalization(): - from src.tools import _filter_links + from src.tools.web import _filter_links out = _filter_links( [ @@ -961,7 +960,7 @@ def test_filter_links_host_case_normalization(): def test_extract_markdown_links_skips_images(): - from src.tools import _extract_markdown_links + from src.tools.web import _extract_markdown_links md = ( "![logo](https://img.example/logo.png) " @@ -974,7 +973,7 @@ def test_extract_markdown_links_skips_images(): def test_extract_markdown_links_preserves_paren_in_url(): - from src.tools import _extract_markdown_links + from src.tools.web import _extract_markdown_links md = "see [Wiki](https://en.wikipedia.org/wiki/Foo_(bar)) for context" out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) @@ -983,7 +982,7 @@ def test_extract_markdown_links_preserves_paren_in_url(): def test_parse_jina_response_inline_markdown_content(): - from src.tools import _parse_jina_response + from src.tools.web import _parse_jina_response payload = "Title: T\nURL Source: https://example.com/\nMarkdown Content: # Heading\n\nBody here." title, body = _parse_jina_response(payload) @@ -1014,10 +1013,10 @@ def _chunked(n=-1): def test_jina_fetch_returns_body_under_cap(): - from src.tools import _jina_fetch + from src.tools.web import _jina_fetch payload = b"Title: x\nURL Source: https://example.com/\n\nBody here." - with patch("src.tools_web.urllib.request.urlopen") as opener: + with patch("src.tools.web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": str(len(payload)), "Content-Type": "text/markdown"} resp.read.side_effect = _streamed_read(payload) @@ -1026,9 +1025,9 @@ def test_jina_fetch_returns_body_under_cap(): def test_jina_fetch_content_length_over_cap(): - from src.tools import _jina_fetch + from src.tools.web import _jina_fetch - with patch("src.tools_web.urllib.request.urlopen") as opener: + with patch("src.tools.web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": "999999"} resp.read.side_effect = _streamed_read(b"x" * 10) @@ -1037,10 +1036,10 @@ def test_jina_fetch_content_length_over_cap(): def test_jina_fetch_streamed_over_cap(): - from src.tools import _jina_fetch + from src.tools.web import _jina_fetch body = b"x" * 2000 - with patch("src.tools_web.urllib.request.urlopen") as opener: + with patch("src.tools.web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {} # no Content-Length resp.read.side_effect = _streamed_read(body) @@ -1049,7 +1048,7 @@ def test_jina_fetch_streamed_over_cap(): def test_raw_fetch_returns_body_under_cap(monkeypatch): - from src.tools import _raw_fetch + from src.tools.web import _raw_fetch body = b"Hihello" @@ -1059,21 +1058,21 @@ def test_raw_fetch_returns_body_under_cap(monkeypatch): cm = fake_opener.open.return_value.__enter__.return_value cm.headers = {"Content-Length": str(len(body)), "Content-Type": "text/html"} cm.read.side_effect = _streamed_read(body) - monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) html = _raw_fetch("https://example.com/", max_bytes=1024) assert "hello" in html def test_raw_fetch_streamed_over_cap(monkeypatch): - from src.tools import _raw_fetch + from src.tools.web import _raw_fetch body = b"a" * 4096 fake_opener = MagicMock() cm = fake_opener.open.return_value.__enter__.return_value cm.headers = {} cm.read.side_effect = _streamed_read(body) - monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) with pytest.raises(ValueError, match="MAX_WEB_BYTES"): _raw_fetch("https://example.com/", max_bytes=1024) @@ -1084,12 +1083,12 @@ def test_raw_fetch_streamed_over_cap(monkeypatch): def _public_dns(monkeypatch): - """Route all src.tools_web.socket.getaddrinfo lookups to a public IP.""" + """Route all src.tools.web.socket.getaddrinfo lookups to a public IP.""" def _public(host, port, family=0, type=0, *args, **kwargs): return [(None, None, None, "", ("93.184.216.34", port))] - monkeypatch.setattr("src.tools_web.socket.getaddrinfo", _public) + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", _public) def test_default_registry_now_includes_fetch_webpage(): @@ -1098,7 +1097,7 @@ def test_default_registry_now_includes_fetch_webpage(): def test_fetch_webpage_rejects_http_via_tool(monkeypatch): - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage ctx = _ctx() with pytest.raises(ValueError, match="https"): @@ -1106,7 +1105,7 @@ def test_fetch_webpage_rejects_http_via_tool(monkeypatch): def test_fetch_webpage_jina_happy_path(monkeypatch): - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage _public_dns(monkeypatch) jina_body = ( @@ -1116,7 +1115,7 @@ def test_fetch_webpage_jina_happy_path(monkeypatch): b"# Hello\n\nSee [Docs](https://docs.example.com/) and [Blog](https://blog.example.com/).\n" ) ctx = _ctx() - with patch("src.tools_web.urllib.request.urlopen") as opener: + with patch("src.tools.web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": str(len(jina_body))} resp.read.side_effect = _streamed_read(jina_body) @@ -1132,7 +1131,7 @@ def test_fetch_webpage_jina_happy_path(monkeypatch): def test_fetch_webpage_falls_back_to_raw_on_jina_5xx(monkeypatch): - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage import urllib.error _public_dns(monkeypatch) @@ -1152,8 +1151,8 @@ def jina_fail(*args, **kwargs): cm.headers = {"Content-Length": str(len(html_body))} cm.read.side_effect = _streamed_read(html_body) - monkeypatch.setattr("src.tools_web.urllib.request.urlopen", jina_fail) - monkeypatch.setattr("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) + monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_fail) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) ctx = _ctx() out = fetch_webpage(ctx, url="https://example.com/") @@ -1165,7 +1164,7 @@ def jina_fail(*args, **kwargs): def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): """Jina oversize → fall through to raw fetch instead of raising.""" - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage _public_dns(monkeypatch) settings = Settings( @@ -1201,11 +1200,11 @@ def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): cm.headers = {"Content-Length": str(len(raw_html))} cm.read.side_effect = _streamed_read(raw_html) - with patch("src.tools_web.urllib.request.urlopen") as jina_open: + with patch("src.tools.web.urllib.request.urlopen") as jina_open: jresp = jina_open.return_value.__enter__.return_value jresp.headers = {} # no Content-Length → streamed-read path jresp.read.side_effect = _streamed_read(huge_jina) - monkeypatch_build = patch("src.tools_web.urllib.request.build_opener", lambda *_: fake_opener) + monkeypatch_build = patch("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) with monkeypatch_build: out = fetch_webpage(ctx, url="https://example.com/") assert out["source"] == "raw" @@ -1213,12 +1212,12 @@ def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): def test_fetch_webpage_max_chars_truncates(monkeypatch): - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage _public_dns(monkeypatch) long_body = b"Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + (b"A" * 500) ctx = _ctx() - with patch("src.tools_web.urllib.request.urlopen") as opener: + with patch("src.tools.web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": str(len(long_body))} resp.read.side_effect = _streamed_read(long_body) @@ -1229,7 +1228,7 @@ def test_fetch_webpage_max_chars_truncates(monkeypatch): def test_fetch_webpage_both_paths_fail_raises(monkeypatch): - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage import urllib.error _public_dns(monkeypatch) @@ -1247,9 +1246,9 @@ def raw_503(*args, **kwargs): fake_opener = MagicMock() fake_opener.open.side_effect = raw_503 - monkeypatch.setattr("src.tools_web.urllib.request.urlopen", jina_500) + monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_500) monkeypatch.setattr( - "src.tools_web.urllib.request.build_opener", lambda *_: fake_opener + "src.tools.web.urllib.request.build_opener", lambda *_: fake_opener ) ctx = _ctx() @@ -1258,7 +1257,7 @@ def raw_503(*args, **kwargs): def test_fetch_webpage_max_links_dedup(monkeypatch): - from src.tools import fetch_webpage + from src.tools.web import fetch_webpage _public_dns(monkeypatch) link_section = ( @@ -1275,7 +1274,7 @@ def test_fetch_webpage_max_links_dedup(monkeypatch): "Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + link_section ).encode() ctx = _ctx() - with patch("src.tools_web.urllib.request.urlopen") as opener: + with patch("src.tools.web.urllib.request.urlopen") as opener: resp = opener.return_value.__enter__.return_value resp.headers = {"Content-Length": str(len(payload))} resp.read.side_effect = _streamed_read(payload) From 2f33871de357f7be6972fd3cbf32573ca201b876 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 16:55:11 +0900 Subject: [PATCH 32/65] refactor(tools): use relative side-effect imports and note time.py stdlib shadow --- src/tools/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 5c07c04..fe0f37e 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -16,7 +16,11 @@ # Side-effect imports: each submodule uses @tool(default_registry, ...) on # one or more functions. Importing them registers those tools. -from src.tools import ( # noqa: F401 (imported for side effects) +# NOTE: 'time' below is src/tools/time.py (the get_current_time tool), +# NOT stdlib time. Do not do `from src.tools import time` expecting +# stdlib — that resolves to our submodule. stdlib time is still safely +# imported as `import time` inside registry.py / slack.py. +from . import ( # noqa: F401 (imported for side effects) image, search, slack, From 9d22f0d613bd57670fdaed67806b3e9656a68903 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:00:42 +0900 Subject: [PATCH 33/65] refactor(tests): split tests/test_tools.py into tests/tools package Co-Authored-By: Claude Sonnet 4.6 --- tests/test_tools.py | 1287 ---------------------------------- tests/tools/__init__.py | 0 tests/tools/_helpers.py | 51 ++ tests/tools/test_image.py | 26 + tests/tools/test_registry.py | 145 ++++ tests/tools/test_search.py | 46 ++ tests/tools/test_slack.py | 464 ++++++++++++ tests/tools/test_time.py | 49 ++ tests/tools/test_web.py | 490 +++++++++++++ 9 files changed, 1271 insertions(+), 1287 deletions(-) delete mode 100644 tests/test_tools.py create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/_helpers.py create mode 100644 tests/tools/test_image.py create mode 100644 tests/tools/test_registry.py create mode 100644 tests/tools/test_search.py create mode 100644 tests/tools/test_slack.py create mode 100644 tests/tools/test_time.py create mode 100644 tests/tools/test_web.py diff --git a/tests/test_tools.py b/tests/test_tools.py deleted file mode 100644 index 9d58b94..0000000 --- a/tests/test_tools.py +++ /dev/null @@ -1,1287 +0,0 @@ -import json -from unittest.mock import MagicMock, patch - -import pytest - -from src.config import Settings -from src.llms import ToolCall -from src.tools import ( - ToolContext, - ToolExecutor, - ToolRegistry, - default_registry, -) -from src.tools.slack import fetch_thread_history, read_attached_images -from src.tools.image import generate_image -from src.tools.search import search_web - - -def _settings(**overrides) -> Settings: - base = { - "slack_bot_token": "xoxb-test", - "slack_signing_secret": "sig", - "llm_provider": "openai", - "llm_model": "gpt-4o-mini", - "image_provider": "openai", - "image_model": "gpt-image-1", - "agent_max_steps": 3, - "response_language": "ko", - "dynamodb_table_name": "t", - "aws_region": "us-east-1", - } - base.update(overrides) - return Settings(**base) - - -def _ctx(event=None, slack_client=None, llm=None): - return ToolContext( - slack_client=slack_client or MagicMock(), - channel="C1", - thread_ts="ts1", - event=event or {}, - settings=_settings(), - llm=llm or MagicMock(), - ) - - -# --------------------------------------------------------------------------- # -# Registry -# --------------------------------------------------------------------------- # - - -def test_default_registry_has_expected_tools(): - names = set(default_registry.names()) - assert { - "read_attached_images", - "fetch_thread_history", - "search_web", - "generate_image", - "get_current_time", - "read_attached_document", - }.issubset(names) - assert "search_slack_messages" not in names # removed — user-token only, tied to installer - - -def test_registry_specs_match_llm_shape(): - for spec in default_registry.specs(): - assert set(spec.keys()) == {"name", "description", "parameters"} - assert spec["parameters"]["type"] == "object" - - -# --------------------------------------------------------------------------- # -# Executor -# --------------------------------------------------------------------------- # - - -def test_executor_unknown_tool(): - registry = ToolRegistry() - executor = ToolExecutor(_ctx(), registry) - result = executor.execute(ToolCall(id="1", name="nope", arguments={})) - assert result["ok"] is False - assert "unknown tool" in result["error"] - - -def test_executor_timeout_guards_slow_tools(): - import time - - registry = ToolRegistry() - - def slow(ctx): - time.sleep(1.0) - - from src.tools.registry import ToolDef - - registry.register(ToolDef(name="slow", description="", parameters={"type": "object", "properties": {}}, fn=slow)) - executor = ToolExecutor(_ctx(), registry, timeout=0.1) - result = executor.execute(ToolCall(id="1", name="slow", arguments={})) - assert result["ok"] is False - assert "timed out" in result["error"] - - -def test_executor_wraps_boto_client_error(): - """Bedrock invoke failures (botocore ClientError) must be returned as - {ok: False, error: ...} so the LLM can plan around the failure instead - of the exception bubbling out of the agent loop.""" - from botocore.exceptions import ClientError - - registry = ToolRegistry() - - def failing_bedrock(ctx): - raise ClientError( - {"Error": {"Code": "ResourceNotFoundException", "Message": "Legacy model"}}, - "InvokeModel", - ) - - from src.tools.registry import ToolDef - - registry.register( - ToolDef( - name="bedrock_thing", - description="", - parameters={"type": "object", "properties": {}}, - fn=failing_bedrock, - ) - ) - executor = ToolExecutor(_ctx(), registry) - result = executor.execute(ToolCall(id="1", name="bedrock_thing", arguments={})) - assert result["ok"] is False - assert "ResourceNotFoundException" in result["error"] or "Legacy" in result["error"] - - -def test_executor_captures_tool_error(): - registry = ToolRegistry() - - def boom(ctx): - raise ValueError("nope") - - from src.tools.registry import ToolDef - - registry.register(ToolDef(name="boom", description="", parameters={"type": "object", "properties": {}}, fn=boom)) - executor = ToolExecutor(_ctx(), registry) - result = executor.execute(ToolCall(id="1", name="boom", arguments={})) - assert result["ok"] is False - assert "nope" in result["error"] - - -def test_executor_per_tool_timeout_override(): - """A tool registered with its own timeout overrides the executor default.""" - import time - - registry = ToolRegistry() - - def moderately_slow(ctx): - time.sleep(0.3) - return "done" - - from src.tools.registry import ToolDef - - registry.register( - ToolDef( - name="slowish", - description="", - parameters={"type": "object", "properties": {}}, - fn=moderately_slow, - timeout=1.0, - ) - ) - # Default timeout short enough to kill a naïve tool; per-tool override lets - # this one finish. - executor = ToolExecutor(_ctx(), registry, timeout=0.1) - result = executor.execute(ToolCall(id="1", name="slowish", arguments={})) - assert result["ok"] is True - assert result["result"] == "done" - - -def test_generate_image_tool_has_extended_timeout(): - """Image generation is slow; its registered timeout must be > default.""" - td = default_registry.get("generate_image") - assert td is not None - assert td.timeout is not None - assert td.timeout >= 60.0 - - -# --------------------------------------------------------------------------- # -# read_attached_images SSRF guard -# --------------------------------------------------------------------------- # - - -def test_read_attached_images_rejects_non_slack_host(): - event = {"files": [{"mimetype": "image/png", "url_private_download": "https://evil.example.com/x.png"}]} - with pytest.raises(ValueError): - read_attached_images(_ctx(event=event), limit=1) - - -def test_read_attached_images_rejects_http_scheme(): - event = {"files": [{"mimetype": "image/png", "url_private_download": "http://files.slack.com/x.png"}]} - with pytest.raises(ValueError): - read_attached_images(_ctx(event=event), limit=1) - - -def test_read_attached_images_accepts_slack_host_variants(): - event = { - "files": [ - {"mimetype": "image/png", "url_private_download": "https://files-pri.slack.com/x.png", "name": "a"}, - ] - } - llm = MagicMock() - llm.describe_image.return_value = "a cat" - ctx = _ctx(event=event, llm=llm) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - opener.return_value.__enter__.return_value.read.return_value = b"fake" - result = read_attached_images(ctx, limit=1) - assert result == [{"name": "a", "summary": "a cat"}] - - -def test_read_attached_images_skips_non_image_mimetypes(): - event = {"files": [{"mimetype": "application/pdf", "url_private_download": "https://files.slack.com/x.pdf"}]} - assert read_attached_images(_ctx(event=event), limit=1) == [] - - -# --------------------------------------------------------------------------- # -# fetch_thread_history -# --------------------------------------------------------------------------- # - - -def test_fetch_thread_history_resolves_user_files_and_reactions(): - """History should carry display names, file metadata, and reactions so the - LLM can answer things like "누가 좋아요 눌렀어?" or "아까 그 이미지 분석해줘".""" - from src.slack_helpers import user_name_cache - - # Reset the module-level cache so prior tests don't leak. - user_name_cache._cache.clear() - - client = MagicMock() - client.conversations_replies.return_value = { - "messages": [ - { - "user": "U1", - "text": "look at this", - "ts": "1713.1", - "files": [ - { - "name": "cat.png", - "mimetype": "image/png", - "url_private_download": "https://files.slack.com/x/cat.png", - "permalink": "https://slack/p1", - "title": "cute", - } - ], - }, - { - "user": "U2", - "text": "nice!", - "ts": "1713.2", - "reactions": [ - {"name": "thumbsup", "count": 2, "users": ["U1", "U3"]}, - ], - }, - ] - } - - def _users_info(user): - return {"user": {"profile": {"display_name": f"name-{user}"}}} - - client.users_info.side_effect = _users_info - - out = fetch_thread_history(_ctx(slack_client=client), limit=5) - assert len(out) == 2 - first, second = out - assert first["user"] == "name-U1" - assert first["text"] == "look at this" - assert first["ts"] == "1713.1" - assert first["files"] == [ - { - "name": "cat.png", - "mimetype": "image/png", - "url_private_download": "https://files.slack.com/x/cat.png", - "permalink": "https://slack/p1", - "title": "cute", - } - ] - assert first["reactions"] == [] - - assert second["user"] == "name-U2" - assert second["files"] == [] - assert second["reactions"] == [ - {"emoji": "thumbsup", "count": 2, "users": ["name-U1", "name-U3"]} - ] - - -def test_read_attached_images_accepts_extra_urls(): - """Images referenced from fetch_thread_history (url_private_download) must - be loadable via read_attached_images(urls=[...]).""" - ctx = _ctx() - ctx.llm.describe_image.return_value = "a cat history" - with patch("src.tools.slack.urllib.request.urlopen") as opener: - opener.return_value.__enter__.return_value.read.return_value = b"fake-bytes" - out = read_attached_images( - ctx, - limit=5, - urls=["https://files.slack.com/x/cat.png"], - ) - assert out == [{"name": "cat.png", "summary": "a cat history"}] - - -def test_read_attached_images_urls_reject_non_slack_host(): - ctx = _ctx() - with pytest.raises(ValueError): - read_attached_images(ctx, urls=["https://evil.example.com/cat.png"]) - - -def test_read_attached_images_respects_total_limit_across_event_and_urls(): - event = { - "files": [ - { - "mimetype": "image/png", - "url_private_download": "https://files.slack.com/e1.png", - "name": "e1.png", - } - ] - } - ctx = _ctx(event=event) - ctx.llm.describe_image.return_value = "desc" - with patch("src.tools.slack.urllib.request.urlopen") as opener: - opener.return_value.__enter__.return_value.read.return_value = b"x" - out = read_attached_images( - ctx, - limit=2, - urls=[ - "https://files.slack.com/u1.png", - "https://files.slack.com/u2.png", # should be skipped (limit=2) - ], - ) - assert len(out) == 2 - assert {item["name"] for item in out} == {"e1.png", "u1.png"} - - -# --------------------------------------------------------------------------- # -# search_web -# --------------------------------------------------------------------------- # - - -def test_search_web_ddg_parses_results(): - ctx = _ctx() - payload = { - "AbstractURL": "https://example.com/a", - "AbstractText": "abstract", - "RelatedTopics": [{"Text": "t1", "FirstURL": "https://example.com/1"}], - } - with patch("src.tools.search.urllib.request.urlopen") as opener: - opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() - results = search_web(ctx, query="q", limit=5) - assert results[0]["url"] == "https://example.com/a" - assert results[1]["url"] == "https://example.com/1" - - -def test_search_web_uses_tavily_when_key_set(): - ctx = ToolContext( - slack_client=MagicMock(), - channel="C1", - thread_ts="ts1", - event={}, - settings=_settings(tavily_api_key="tvly-xyz"), - llm=MagicMock(), - ) - payload = {"results": [{"title": "t", "url": "https://x", "content": "c"}]} - with patch("src.tools.search.urllib.request.urlopen") as opener: - opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() - out = search_web(ctx, query="q", limit=5) - assert out == [{"title": "t", "url": "https://x", "content": "c"}] - - -# --------------------------------------------------------------------------- # -# generate_image -# --------------------------------------------------------------------------- # - - -def test_generate_image_returns_permalink(): - llm = MagicMock() - llm.generate_image.return_value = b"imgbytes" - client = MagicMock() - client.files_upload_v2.return_value = {"file": {"permalink": "https://slack/abc", "title": "t"}} - ctx = _ctx(slack_client=client, llm=llm) - out = generate_image(ctx, prompt="cat") - assert out == {"permalink": "https://slack/abc", "title": "t"} - llm.generate_image.assert_called_once_with("cat") - - -# --------------------------------------------------------------------------- # -# get_current_time -# --------------------------------------------------------------------------- # - - -def test_get_current_time_uses_default_timezone(): - from src.tools.time import get_current_time - - ctx = _ctx() # _settings() default_timezone defaults to Asia/Seoul - out = get_current_time(ctx) - assert out["timezone"] == "Asia/Seoul" - assert out["iso"].endswith("+09:00") - # Weekday is a full English day name (Monday..Sunday) - assert out["weekday"] in { - "Monday", "Tuesday", "Wednesday", "Thursday", - "Friday", "Saturday", "Sunday", - } - assert isinstance(out["unix"], int) - - -def test_get_current_time_respects_custom_timezone(): - from src.tools.time import get_current_time - - ctx = _ctx() - out = get_current_time(ctx, timezone="UTC") - assert out["timezone"] == "UTC" - assert out["iso"].endswith("+00:00") - - -def test_get_current_time_invalid_tz_via_executor(): - """Invalid timezone should surface as {ok: False, error: ...} via the - executor so the LLM can recover.""" - from src.tools import default_registry - - executor = ToolExecutor(_ctx(), default_registry) - result = executor.execute( - ToolCall(id="t1", name="get_current_time", arguments={"timezone": "Narnia/Center"}) - ) - assert result["ok"] is False - assert "unknown timezone" in result["error"] - - -# --------------------------------------------------------------------------- # -# read_attached_document -# --------------------------------------------------------------------------- # - - -def test_read_attached_document_text_file(): - from src.tools.slack import read_attached_document - - event = { - "files": [ - { - "mimetype": "text/plain", - "url_private_download": "https://files.slack.com/notes.txt", - "name": "notes.txt", - } - ] - } - ctx = _ctx(event=event) - body = b"Hello\n world.\nLine 3." - with patch("src.tools.slack.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.read.return_value = body - resp.headers = {"Content-Length": str(len(body))} - out = read_attached_document(ctx, limit=1) - assert len(out) == 1 - entry = out[0] - assert entry["name"] == "notes.txt" - assert entry["mimetype"] == "text/plain" - assert entry["truncated"] is False - assert "Hello" in entry["text"] - assert entry["chars"] == len(entry["text"]) - assert entry["pages"] == 0 # text files report 0 pages - - -def _build_pdf_bytes(pages_text: list[str]) -> bytes: - """Build a minimal PDF (one page per string) using reportlab. Test-only.""" - from io import BytesIO - from reportlab.pdfgen.canvas import Canvas - from reportlab.lib.pagesizes import letter - - buf = BytesIO() - canvas = Canvas(buf, pagesize=letter) - for text in pages_text: - canvas.drawString(72, 720, text) - canvas.showPage() - canvas.save() - return buf.getvalue() - - -def _mock_pdf_response(opener, body: bytes, headers=None): - """Wire the urlopen mock to stream `body` in chunks through `_fetch_slack_file`.""" - resp = opener.return_value.__enter__.return_value - buf = {"pos": 0} - - def _chunked(n=-1): - if n == -1: - remaining = body[buf["pos"]:] - buf["pos"] = len(body) - return remaining - chunk = body[buf["pos"]:buf["pos"] + n] - buf["pos"] += len(chunk) - return chunk - - resp.read.side_effect = _chunked - resp.headers = dict(headers or {"Content-Length": str(len(body)), "Content-Type": "application/pdf"}) - - -def test_read_attached_document_pdf_happy_path(): - from src.tools.slack import read_attached_document - - pdf = _build_pdf_bytes(["Hello PDF page one.", "Page two here."]) - event = { - "files": [ - { - "mimetype": "application/pdf", - "url_private_download": "https://files.slack.com/report.pdf", - "name": "report.pdf", - } - ] - } - ctx = _ctx(event=event) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - _mock_pdf_response(opener, pdf) - out = read_attached_document(ctx, limit=1) - assert len(out) == 1 - entry = out[0] - assert entry["name"] == "report.pdf" - assert entry["pages"] == 2 - assert entry["truncated"] is False - assert entry["chars"] > 0 - - -def test_read_attached_document_pdf_truncation(): - from src.tools.slack import read_attached_document - - pdf = _build_pdf_bytes(["A" * 500]) - event = { - "files": [ - { - "mimetype": "application/pdf", - "url_private_download": "https://files.slack.com/big.pdf", - "name": "big.pdf", - } - ] - } - ctx = _ctx( - event=event, - ) - ctx = ToolContext( - slack_client=ctx.slack_client, - channel=ctx.channel, - thread_ts=ctx.thread_ts, - event=ctx.event, - settings=_settings(max_doc_chars=50), - llm=ctx.llm, - ) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - _mock_pdf_response(opener, pdf) - out = read_attached_document(ctx, limit=1) - assert out[0]["truncated"] is True - assert out[0]["chars"] == 50 - - -def test_read_attached_document_page_cap(): - from src.tools.slack import read_attached_document - - pdf = _build_pdf_bytes(["p1", "p2", "p3"]) - event = { - "files": [ - { - "mimetype": "application/pdf", - "url_private_download": "https://files.slack.com/pages.pdf", - "name": "pages.pdf", - } - ] - } - ctx = ToolContext( - slack_client=MagicMock(), - channel="C1", - thread_ts="ts1", - event=event, - settings=_settings(max_doc_pages=2), - llm=MagicMock(), - ) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - _mock_pdf_response(opener, pdf) - out = read_attached_document(ctx, limit=1) - assert "error" in out[0] - assert "MAX_DOC_PAGES" in out[0]["error"] - - -def test_read_attached_document_size_cap_via_content_length(): - from src.tools.slack import read_attached_document - - event = { - "files": [ - { - "mimetype": "text/plain", - "url_private_download": "https://files.slack.com/huge.txt", - "name": "huge.txt", - } - ] - } - ctx = ToolContext( - slack_client=MagicMock(), - channel="C1", - thread_ts="ts1", - event=event, - settings=_settings(max_doc_bytes=100), # tiny cap - llm=MagicMock(), - ) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {"Content-Length": "200"} # > cap - resp.read.return_value = b"x" * 10 # should never be read past cap - out = read_attached_document(ctx, limit=1) - assert "error" in out[0] - assert "MAX_DOC_BYTES" in out[0]["error"] - - -def test_read_attached_document_size_cap_via_streamed_read(): - from src.tools.slack import read_attached_document - - event = { - "files": [ - { - "mimetype": "text/plain", - "url_private_download": "https://files.slack.com/nohead.txt", - "name": "nohead.txt", - } - ] - } - ctx = ToolContext( - slack_client=MagicMock(), - channel="C1", - thread_ts="ts1", - event=event, - settings=_settings(max_doc_bytes=100), - llm=MagicMock(), - ) - body = b"y" * 200 - with patch("src.tools.slack.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {} # no Content-Length - buf = {"pos": 0} - - def _chunked(n=-1): - if n == -1: - remaining = body[buf["pos"]:] - buf["pos"] = len(body) - return remaining - chunk = body[buf["pos"]:buf["pos"] + n] - buf["pos"] += len(chunk) - return chunk - - resp.read.side_effect = _chunked - out = read_attached_document(ctx, limit=1) - assert "error" in out[0] - assert "MAX_DOC_BYTES" in out[0]["error"] - - -def test_read_attached_document_rejects_non_slack_host(): - from src.tools.slack import read_attached_document - - ctx = _ctx() - out = read_attached_document( - ctx, urls=["https://evil.example.com/foo.pdf"], limit=1 - ) - assert len(out) == 1 - assert "error" in out[0] - assert "invalid" in out[0]["error"].lower() - - -def test_read_attached_document_skips_encrypted_pdf(): - from src.tools.slack import read_attached_document - from io import BytesIO - from pypdf import PdfWriter - - writer = PdfWriter() - writer.add_blank_page(width=612, height=792) - # NOTE: pypdf>=4.0 uses keyword-only user_password. If requirements.txt's - # upper pin is ever relaxed past 6.0, verify this signature still holds. - writer.encrypt(user_password="secret") - buf = BytesIO() - writer.write(buf) - encrypted_pdf = buf.getvalue() - - event = { - "files": [ - { - "mimetype": "application/pdf", - "url_private_download": "https://files.slack.com/enc.pdf", - "name": "enc.pdf", - } - ] - } - ctx = _ctx(event=event) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - _mock_pdf_response(opener, encrypted_pdf) - out = read_attached_document(ctx, limit=1) - assert "error" in out[0] - assert "encrypted" in out[0]["error"] - - -def test_read_attached_document_skips_image_mime(): - from src.tools.slack import read_attached_document - - event = { - "files": [ - { - "mimetype": "image/png", - "url_private_download": "https://files.slack.com/a.png", - "name": "a.png", - } - ] - } - ctx = _ctx(event=event) - # urlopen should NOT be called — image MIMEs are filtered before fetch - with patch("src.tools.slack.urllib.request.urlopen") as opener: - out = read_attached_document(ctx, limit=1) - opener.assert_not_called() - assert out == [] - - -def test_read_attached_document_http_error_returns_per_item(): - from src.tools.slack import read_attached_document - import urllib.error - - event = { - "files": [ - { - "mimetype": "application/pdf", - "url_private_download": "https://files.slack.com/missing.pdf", - "name": "missing.pdf", - } - ] - } - ctx = _ctx(event=event) - with patch("src.tools.slack.urllib.request.urlopen") as opener: - opener.side_effect = urllib.error.HTTPError( - url="https://files.slack.com/missing.pdf", - code=404, - msg="Not Found", - hdrs=None, - fp=None, - ) - out = read_attached_document(ctx, limit=1) - assert len(out) == 1 - assert "error" in out[0] - assert "404" in out[0]["error"] - - -# --------------------------------------------------------------------------- # -# fetch_webpage — SSRF guard -# --------------------------------------------------------------------------- # - - -def test_validate_public_https_url_rejects_http_scheme(): - from src.tools.web import _validate_public_https_url - - with pytest.raises(ValueError, match="https"): - _validate_public_https_url("http://example.com/") - - -def test_validate_public_https_url_rejects_ip_literal_v4(): - from src.tools.web import _validate_public_https_url - - with pytest.raises(ValueError, match="IP literals"): - _validate_public_https_url("https://127.0.0.1/") - - -def test_validate_public_https_url_rejects_ip_literal_v6(): - from src.tools.web import _validate_public_https_url - - with pytest.raises(ValueError, match="IP literals"): - _validate_public_https_url("https://[::1]/") - - -def test_validate_public_https_url_rejects_private_dns(monkeypatch): - from src.tools.web import _validate_public_https_url - - def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): - # Simulate DNS pointing at RFC1918 space. - return [(None, None, None, "", ("10.0.0.1", port))] - - monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) - with pytest.raises(ValueError, match="non-public"): - _validate_public_https_url("https://internal.corp.example/") - - -def test_validate_public_https_url_rejects_metadata_host(monkeypatch): - from src.tools.web import _validate_public_https_url - - def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): - return [(None, None, None, "", ("169.254.169.254", port))] - - monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) - with pytest.raises(ValueError, match="non-public"): - _validate_public_https_url("https://cloud.metadata.example/") - - -def test_validate_public_https_url_accepts_public_host(monkeypatch): - from src.tools.web import _validate_public_https_url - - def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): - return [(None, None, None, "", ("93.184.216.34", port))] # example.com - - monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) - scheme, host = _validate_public_https_url("https://example.com/path") - assert scheme == "https" - assert host == "example.com" - - -def test_validate_public_https_url_dns_failure(monkeypatch): - import socket as _socket - - from src.tools.web import _validate_public_https_url - - def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): - raise _socket.gaierror("nodename nor servname provided") - - monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) - with pytest.raises(ValueError, match="DNS resolution failed"): - _validate_public_https_url("https://nonexistent.invalid.example/") - - -def test_no_redirect_handler_raises_on_302(): - import urllib.error - import urllib.request - - from src.tools.web import _NoRedirectHandler - - handler = _NoRedirectHandler() - req = urllib.request.Request("https://example.com/") - with pytest.raises(urllib.error.HTTPError, match="redirects not allowed"): - handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example/") - - -# --------------------------------------------------------------------------- # -# fetch_webpage — HTML parser + Jina response parser -# --------------------------------------------------------------------------- # - - -def test_html_text_extractor_basic(): - from src.tools.web import _HtmlTextExtractor - - html = ( - " Hello " - "" - "" - "" - "

Heading

" - "

Para one.

" - "

Para two.

" - "Link A" - "Rel" - "Frag" - "Mail" - "" - ) - x = _HtmlTextExtractor("https://base.example/page") - x.feed(html) - assert x.title() == "Hello" - text = x.text() - assert "Heading" in text - assert "Para one." in text - assert "Para two." in text - assert "alert" not in text - assert ".a{}" not in text - assert ("Link A", "https://a.example/1") in x.links - # Relative links resolved against base - assert ("Rel", "https://base.example/relative") in x.links - # mailto / fragment retained raw — filtering happens in _filter_links - assert any(url.startswith("mailto:") for _, url in x.links) - - -def test_filter_links_drops_non_https_and_dedups(): - from src.tools.web import _filter_links - - raw = [ - ("A", "https://a.example/1"), - ("A dup", "https://a.example/1#top"), # dedups by fragment-stripped url - ("B", "https://b.example/"), - ("Self", "https://base.example/page"), # self-ref dropped - ("Mail", "mailto:x@y"), - ("JS", "javascript:void(0)"), - ("HTTP", "http://insecure.example/"), - ] - out = _filter_links(raw, base_url="https://base.example/page", limit=10) - urls = [item["url"] for item in out] - assert urls == ["https://a.example/1", "https://b.example/"] - assert out[0]["title"] == "A" - - -def test_filter_links_respects_limit(): - from src.tools.web import _filter_links - - raw = [(f"T{i}", f"https://x.example/{i}") for i in range(5)] - out = _filter_links(raw, base_url="https://base.example/", limit=3) - assert len(out) == 3 - assert [item["url"] for item in out] == [ - "https://x.example/0", - "https://x.example/1", - "https://x.example/2", - ] - - -def test_extract_markdown_links_parses_inline_markdown(): - from src.tools.web import _extract_markdown_links - - md = ( - "Title\n\nSome prose with [Google](https://google.com/about) " - "and [Self](https://base.example/page) and [Same](https://google.com/about?ref=x).\n" - "[Another](https://example.org/)" - ) - out = _extract_markdown_links(md, base_url="https://base.example/page", limit=10) - urls = [item["url"] for item in out] - assert "https://google.com/about" in urls - assert "https://example.org/" in urls - assert "https://base.example/page" not in urls # self-ref dropped - - -def test_parse_jina_response_strips_header(): - from src.tools.web import _parse_jina_response - - payload = ( - "Title: Example Page\n" - "URL Source: https://example.com/\n" - "Markdown Content:\n" - "# Heading\n\nBody text.\n" - ) - title, body = _parse_jina_response(payload) - assert title == "Example Page" - assert body.startswith("# Heading") - assert "URL Source" not in body - - -def test_parse_jina_response_no_header(): - from src.tools.web import _parse_jina_response - - payload = "just raw markdown\n\nwith no prefix" - title, body = _parse_jina_response(payload) - assert title == "" - assert body == payload - - -def test_html_text_extractor_empty(): - from src.tools.web import _HtmlTextExtractor - - x = _HtmlTextExtractor("https://base.example/") - x.feed("") - assert x.title() == "" - assert x.text() == "" - assert x.links == [] - - -def test_filter_links_host_case_normalization(): - from src.tools.web import _filter_links - - out = _filter_links( - [ - ("A", "https://Example.COM/path"), - ("B", "https://example.com/path"), # dup by host case - ("Self", "https://BASE.EXAMPLE/page"), # self-ref by host case - ], - base_url="https://base.example/page", - limit=10, - ) - urls = [item["url"] for item in out] - assert urls == ["https://Example.COM/path"] # first-seen wins, case preserved in output - - -def test_extract_markdown_links_skips_images(): - from src.tools.web import _extract_markdown_links - - md = ( - "![logo](https://img.example/logo.png) " - "see [Docs](https://docs.example/) for more" - ) - out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) - urls = [item["url"] for item in out] - assert "https://img.example/logo.png" not in urls - assert "https://docs.example/" in urls - - -def test_extract_markdown_links_preserves_paren_in_url(): - from src.tools.web import _extract_markdown_links - - md = "see [Wiki](https://en.wikipedia.org/wiki/Foo_(bar)) for context" - out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) - urls = [item["url"] for item in out] - assert "https://en.wikipedia.org/wiki/Foo_(bar)" in urls - - -def test_parse_jina_response_inline_markdown_content(): - from src.tools.web import _parse_jina_response - - payload = "Title: T\nURL Source: https://example.com/\nMarkdown Content: # Heading\n\nBody here." - title, body = _parse_jina_response(payload) - assert title == "T" - assert body.startswith("# Heading") - assert "Body here." in body - - -# --------------------------------------------------------------------------- # -# fetch_webpage — fetch helpers -# --------------------------------------------------------------------------- # - - -def _streamed_read(body: bytes): - """Build a urlopen-mock read side_effect that serves `body` in chunks.""" - buf = {"pos": 0} - - def _chunked(n=-1): - if n == -1: - remaining = body[buf["pos"]:] - buf["pos"] = len(body) - return remaining - chunk = body[buf["pos"]:buf["pos"] + n] - buf["pos"] += len(chunk) - return chunk - - return _chunked - - -def test_jina_fetch_returns_body_under_cap(): - from src.tools.web import _jina_fetch - - payload = b"Title: x\nURL Source: https://example.com/\n\nBody here." - with patch("src.tools.web.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {"Content-Length": str(len(payload)), "Content-Type": "text/markdown"} - resp.read.side_effect = _streamed_read(payload) - text = _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) - assert "Body here." in text - - -def test_jina_fetch_content_length_over_cap(): - from src.tools.web import _jina_fetch - - with patch("src.tools.web.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {"Content-Length": "999999"} - resp.read.side_effect = _streamed_read(b"x" * 10) - with pytest.raises(ValueError, match="MAX_WEB_BYTES"): - _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) - - -def test_jina_fetch_streamed_over_cap(): - from src.tools.web import _jina_fetch - - body = b"x" * 2000 - with patch("src.tools.web.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {} # no Content-Length - resp.read.side_effect = _streamed_read(body) - with pytest.raises(ValueError, match="MAX_WEB_BYTES"): - _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) - - -def test_raw_fetch_returns_body_under_cap(monkeypatch): - from src.tools.web import _raw_fetch - - body = b"Hihello" - - # _raw_fetch builds its own opener (with _NoRedirectHandler) and calls opener.open. - # Patch urllib.request.build_opener so we intercept that call. - fake_opener = MagicMock() - cm = fake_opener.open.return_value.__enter__.return_value - cm.headers = {"Content-Length": str(len(body)), "Content-Type": "text/html"} - cm.read.side_effect = _streamed_read(body) - monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) - - html = _raw_fetch("https://example.com/", max_bytes=1024) - assert "hello" in html - - -def test_raw_fetch_streamed_over_cap(monkeypatch): - from src.tools.web import _raw_fetch - - body = b"a" * 4096 - fake_opener = MagicMock() - cm = fake_opener.open.return_value.__enter__.return_value - cm.headers = {} - cm.read.side_effect = _streamed_read(body) - monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) - with pytest.raises(ValueError, match="MAX_WEB_BYTES"): - _raw_fetch("https://example.com/", max_bytes=1024) - - -# --------------------------------------------------------------------------- # -# fetch_webpage — end-to-end via tool function -# --------------------------------------------------------------------------- # - - -def _public_dns(monkeypatch): - """Route all src.tools.web.socket.getaddrinfo lookups to a public IP.""" - - def _public(host, port, family=0, type=0, *args, **kwargs): - return [(None, None, None, "", ("93.184.216.34", port))] - - monkeypatch.setattr("src.tools.web.socket.getaddrinfo", _public) - - -def test_default_registry_now_includes_fetch_webpage(): - names = set(default_registry.names()) - assert "fetch_webpage" in names - - -def test_fetch_webpage_rejects_http_via_tool(monkeypatch): - from src.tools.web import fetch_webpage - - ctx = _ctx() - with pytest.raises(ValueError, match="https"): - fetch_webpage(ctx, url="http://example.com/") - - -def test_fetch_webpage_jina_happy_path(monkeypatch): - from src.tools.web import fetch_webpage - - _public_dns(monkeypatch) - jina_body = ( - b"Title: Example Page\n" - b"URL Source: https://example.com/\n" - b"Markdown Content:\n" - b"# Hello\n\nSee [Docs](https://docs.example.com/) and [Blog](https://blog.example.com/).\n" - ) - ctx = _ctx() - with patch("src.tools.web.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {"Content-Length": str(len(jina_body))} - resp.read.side_effect = _streamed_read(jina_body) - out = fetch_webpage(ctx, url="https://example.com/") - assert out["source"] == "jina" - assert out["title"] == "Example Page" - assert "Hello" in out["content"] - urls = [link["url"] for link in out["links"]] - assert "https://docs.example.com/" in urls - assert "https://blog.example.com/" in urls - assert out["truncated"] is False - assert out["chars"] == len(out["content"]) - - -def test_fetch_webpage_falls_back_to_raw_on_jina_5xx(monkeypatch): - from src.tools.web import fetch_webpage - import urllib.error - - _public_dns(monkeypatch) - html_body = ( - b"Raw Title" - b"

Raw body.

" - b"Docs" - ) - - def jina_fail(*args, **kwargs): - raise urllib.error.HTTPError( - "https://r.jina.ai/https://example.com/", 500, "boom", {}, None - ) - - fake_opener = MagicMock() - cm = fake_opener.open.return_value.__enter__.return_value - cm.headers = {"Content-Length": str(len(html_body))} - cm.read.side_effect = _streamed_read(html_body) - - monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_fail) - monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) - - ctx = _ctx() - out = fetch_webpage(ctx, url="https://example.com/") - assert out["source"] == "raw" - assert out["title"] == "Raw Title" - assert "Raw body." in out["content"] - assert any(link["url"] == "https://docs.example.com/" for link in out["links"]) - - -def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): - """Jina oversize → fall through to raw fetch instead of raising.""" - from src.tools.web import fetch_webpage - - _public_dns(monkeypatch) - settings = Settings( - slack_bot_token="xoxb-test", - slack_signing_secret="sig", - llm_provider="openai", - llm_model="gpt-4o-mini", - image_provider="openai", - image_model="gpt-image-1", - agent_max_steps=3, - response_language="ko", - dynamodb_table_name="t", - aws_region="us-east-1", - max_web_bytes=128, - max_web_chars=8000, - max_web_links=20, - jina_reader_base="https://r.jina.ai", - ) - ctx = ToolContext( - slack_client=MagicMock(), - channel="C1", - thread_ts="ts1", - event={}, - settings=settings, - llm=MagicMock(), - ) - - huge_jina = b"x" * 4096 - raw_html = b"

raw small body

" - - fake_opener = MagicMock() - cm = fake_opener.open.return_value.__enter__.return_value - cm.headers = {"Content-Length": str(len(raw_html))} - cm.read.side_effect = _streamed_read(raw_html) - - with patch("src.tools.web.urllib.request.urlopen") as jina_open: - jresp = jina_open.return_value.__enter__.return_value - jresp.headers = {} # no Content-Length → streamed-read path - jresp.read.side_effect = _streamed_read(huge_jina) - monkeypatch_build = patch("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) - with monkeypatch_build: - out = fetch_webpage(ctx, url="https://example.com/") - assert out["source"] == "raw" - assert "raw small body" in out["content"] - - -def test_fetch_webpage_max_chars_truncates(monkeypatch): - from src.tools.web import fetch_webpage - - _public_dns(monkeypatch) - long_body = b"Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + (b"A" * 500) - ctx = _ctx() - with patch("src.tools.web.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {"Content-Length": str(len(long_body))} - resp.read.side_effect = _streamed_read(long_body) - out = fetch_webpage(ctx, url="https://example.com/", max_chars=200) - assert out["truncated"] is True - assert out["chars"] == 200 - assert len(out["content"]) == 200 - - -def test_fetch_webpage_both_paths_fail_raises(monkeypatch): - from src.tools.web import fetch_webpage - import urllib.error - - _public_dns(monkeypatch) - - def jina_500(*args, **kwargs): - raise urllib.error.HTTPError( - "https://r.jina.ai/...", 500, "jina boom", {}, None - ) - - def raw_503(*args, **kwargs): - raise urllib.error.HTTPError( - "https://example.com/", 503, "raw boom", {}, None - ) - - fake_opener = MagicMock() - fake_opener.open.side_effect = raw_503 - - monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_500) - monkeypatch.setattr( - "src.tools.web.urllib.request.build_opener", lambda *_: fake_opener - ) - - ctx = _ctx() - with pytest.raises(ValueError, match=r"jina=.*raw="): - fetch_webpage(ctx, url="https://example.com/") - - -def test_fetch_webpage_max_links_dedup(monkeypatch): - from src.tools.web import fetch_webpage - - _public_dns(monkeypatch) - link_section = ( - "[a](https://a.example/)" - "[b](https://b.example/)" - "[c](https://c.example/)" - "[d](https://d.example/)" - "[dup-a](https://a.example/)" - "[e](https://e.example/)" - "[dup-b](https://b.example/)" - "[f](https://f.example/)" - ) - payload = ( - "Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + link_section - ).encode() - ctx = _ctx() - with patch("src.tools.web.urllib.request.urlopen") as opener: - resp = opener.return_value.__enter__.return_value - resp.headers = {"Content-Length": str(len(payload))} - resp.read.side_effect = _streamed_read(payload) - out = fetch_webpage(ctx, url="https://example.com/", max_links=3) - urls = [link["url"] for link in out["links"]] - assert urls == [ - "https://a.example/", - "https://b.example/", - "https://c.example/", - ] diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/_helpers.py b/tests/tools/_helpers.py new file mode 100644 index 0000000..465fdd4 --- /dev/null +++ b/tests/tools/_helpers.py @@ -0,0 +1,51 @@ +"""Shared fixtures for tests/tools/ — _settings, _ctx, _streamed_read.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from src.config import Settings +from src.tools.registry import ToolContext + + +def _settings(**overrides) -> Settings: + base = { + "slack_bot_token": "xoxb-test", + "slack_signing_secret": "sig", + "llm_provider": "openai", + "llm_model": "gpt-4o-mini", + "image_provider": "openai", + "image_model": "gpt-image-1", + "agent_max_steps": 3, + "response_language": "ko", + "dynamodb_table_name": "t", + "aws_region": "us-east-1", + } + base.update(overrides) + return Settings(**base) + + +def _ctx(event=None, slack_client=None, llm=None): + return ToolContext( + slack_client=slack_client or MagicMock(), + channel="C1", + thread_ts="ts1", + event=event or {}, + settings=_settings(), + llm=llm or MagicMock(), + ) + + +def _streamed_read(body: bytes): + """Build a urlopen-mock read side_effect that serves `body` in chunks.""" + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + return _chunked diff --git a/tests/tools/test_image.py b/tests/tools/test_image.py new file mode 100644 index 0000000..e6f03f2 --- /dev/null +++ b/tests/tools/test_image.py @@ -0,0 +1,26 @@ +"""Tests for src.tools.image.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.tools.image import generate_image + + +# --------------------------------------------------------------------------- # +# generate_image +# --------------------------------------------------------------------------- # + + +def test_generate_image_returns_permalink(): + llm = MagicMock() + llm.generate_image.return_value = b"imgbytes" + client = MagicMock() + client.files_upload_v2.return_value = {"file": {"permalink": "https://slack/abc", "title": "t"}} + ctx = _ctx(slack_client=client, llm=llm) + out = generate_image(ctx, prompt="cat") + assert out == {"permalink": "https://slack/abc", "title": "t"} + llm.generate_image.assert_called_once_with("cat") diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py new file mode 100644 index 0000000..f22615e --- /dev/null +++ b/tests/tools/test_registry.py @@ -0,0 +1,145 @@ +"""Tests for src.tools.registry.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.llms import ToolCall +from src.tools import default_registry +from src.tools.registry import ToolDef, ToolExecutor, ToolRegistry + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # + + +def test_default_registry_has_expected_tools(): + names = set(default_registry.names()) + assert { + "read_attached_images", + "fetch_thread_history", + "search_web", + "generate_image", + "get_current_time", + "read_attached_document", + }.issubset(names) + assert "search_slack_messages" not in names # removed — user-token only, tied to installer + + +def test_registry_specs_match_llm_shape(): + for spec in default_registry.specs(): + assert set(spec.keys()) == {"name", "description", "parameters"} + assert spec["parameters"]["type"] == "object" + + +# --------------------------------------------------------------------------- # +# Executor +# --------------------------------------------------------------------------- # + + +def test_executor_unknown_tool(): + registry = ToolRegistry() + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="nope", arguments={})) + assert result["ok"] is False + assert "unknown tool" in result["error"] + + +def test_executor_timeout_guards_slow_tools(): + import time + + registry = ToolRegistry() + + def slow(ctx): + time.sleep(1.0) + + registry.register(ToolDef(name="slow", description="", parameters={"type": "object", "properties": {}}, fn=slow)) + executor = ToolExecutor(_ctx(), registry, timeout=0.1) + result = executor.execute(ToolCall(id="1", name="slow", arguments={})) + assert result["ok"] is False + assert "timed out" in result["error"] + + +def test_executor_wraps_boto_client_error(): + """Bedrock invoke failures (botocore ClientError) must be returned as + {ok: False, error: ...} so the LLM can plan around the failure instead + of the exception bubbling out of the agent loop.""" + from botocore.exceptions import ClientError + + registry = ToolRegistry() + + def failing_bedrock(ctx): + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Legacy model"}}, + "InvokeModel", + ) + + registry.register( + ToolDef( + name="bedrock_thing", + description="", + parameters={"type": "object", "properties": {}}, + fn=failing_bedrock, + ) + ) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="bedrock_thing", arguments={})) + assert result["ok"] is False + assert "ResourceNotFoundException" in result["error"] or "Legacy" in result["error"] + + +def test_executor_captures_tool_error(): + registry = ToolRegistry() + + def boom(ctx): + raise ValueError("nope") + + registry.register(ToolDef(name="boom", description="", parameters={"type": "object", "properties": {}}, fn=boom)) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="boom", arguments={})) + assert result["ok"] is False + assert "nope" in result["error"] + + +def test_executor_per_tool_timeout_override(): + """A tool registered with its own timeout overrides the executor default.""" + import time + + registry = ToolRegistry() + + def moderately_slow(ctx): + time.sleep(0.3) + return "done" + + registry.register( + ToolDef( + name="slowish", + description="", + parameters={"type": "object", "properties": {}}, + fn=moderately_slow, + timeout=1.0, + ) + ) + # Default timeout short enough to kill a naïve tool; per-tool override lets + # this one finish. + executor = ToolExecutor(_ctx(), registry, timeout=0.1) + result = executor.execute(ToolCall(id="1", name="slowish", arguments={})) + assert result["ok"] is True + assert result["result"] == "done" + + +def test_generate_image_tool_has_extended_timeout(): + """Image generation is slow; its registered timeout must be > default.""" + td = default_registry.get("generate_image") + assert td is not None + assert td.timeout is not None + assert td.timeout >= 60.0 + + +def test_default_registry_now_includes_fetch_webpage(): + names = set(default_registry.names()) + assert "fetch_webpage" in names diff --git a/tests/tools/test_search.py b/tests/tools/test_search.py new file mode 100644 index 0000000..dcf47ab --- /dev/null +++ b/tests/tools/test_search.py @@ -0,0 +1,46 @@ +"""Tests for src.tools.search.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.tools.registry import ToolContext +from src.tools.search import search_web + + +# --------------------------------------------------------------------------- # +# search_web +# --------------------------------------------------------------------------- # + + +def test_search_web_ddg_parses_results(): + ctx = _ctx() + payload = { + "AbstractURL": "https://example.com/a", + "AbstractText": "abstract", + "RelatedTopics": [{"Text": "t1", "FirstURL": "https://example.com/1"}], + } + with patch("src.tools.search.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() + results = search_web(ctx, query="q", limit=5) + assert results[0]["url"] == "https://example.com/a" + assert results[1]["url"] == "https://example.com/1" + + +def test_search_web_uses_tavily_when_key_set(): + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=_settings(tavily_api_key="tvly-xyz"), + llm=MagicMock(), + ) + payload = {"results": [{"title": "t", "url": "https://x", "content": "c"}]} + with patch("src.tools.search.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = json.dumps(payload).encode() + out = search_web(ctx, query="q", limit=5) + assert out == [{"title": "t", "url": "https://x", "content": "c"}] diff --git a/tests/tools/test_slack.py b/tests/tools/test_slack.py new file mode 100644 index 0000000..7dc70cb --- /dev/null +++ b/tests/tools/test_slack.py @@ -0,0 +1,464 @@ +"""Tests for src.tools.slack.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.llms import ToolCall +from src.tools.registry import ToolContext, ToolExecutor +from src.tools.slack import ( + fetch_thread_history, + read_attached_document, + read_attached_images, +) + + +# --------------------------------------------------------------------------- # +# read_attached_images SSRF guard +# --------------------------------------------------------------------------- # + + +def test_read_attached_images_rejects_non_slack_host(): + event = {"files": [{"mimetype": "image/png", "url_private_download": "https://evil.example.com/x.png"}]} + with pytest.raises(ValueError): + read_attached_images(_ctx(event=event), limit=1) + + +def test_read_attached_images_rejects_http_scheme(): + event = {"files": [{"mimetype": "image/png", "url_private_download": "http://files.slack.com/x.png"}]} + with pytest.raises(ValueError): + read_attached_images(_ctx(event=event), limit=1) + + +def test_read_attached_images_accepts_slack_host_variants(): + event = { + "files": [ + {"mimetype": "image/png", "url_private_download": "https://files-pri.slack.com/x.png", "name": "a"}, + ] + } + llm = MagicMock() + llm.describe_image.return_value = "a cat" + ctx = _ctx(event=event, llm=llm) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"fake" + result = read_attached_images(ctx, limit=1) + assert result == [{"name": "a", "summary": "a cat"}] + + +def test_read_attached_images_skips_non_image_mimetypes(): + event = {"files": [{"mimetype": "application/pdf", "url_private_download": "https://files.slack.com/x.pdf"}]} + assert read_attached_images(_ctx(event=event), limit=1) == [] + + +# --------------------------------------------------------------------------- # +# fetch_thread_history +# --------------------------------------------------------------------------- # + + +def test_fetch_thread_history_resolves_user_files_and_reactions(): + """History should carry display names, file metadata, and reactions so the + LLM can answer things like "누가 좋아요 눌렀어?" or "아까 그 이미지 분석해줘".""" + from src.slack_helpers import user_name_cache + + # Reset the module-level cache so prior tests don't leak. + user_name_cache._cache.clear() + + client = MagicMock() + client.conversations_replies.return_value = { + "messages": [ + { + "user": "U1", + "text": "look at this", + "ts": "1713.1", + "files": [ + { + "name": "cat.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/x/cat.png", + "permalink": "https://slack/p1", + "title": "cute", + } + ], + }, + { + "user": "U2", + "text": "nice!", + "ts": "1713.2", + "reactions": [ + {"name": "thumbsup", "count": 2, "users": ["U1", "U3"]}, + ], + }, + ] + } + + def _users_info(user): + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client.users_info.side_effect = _users_info + + out = fetch_thread_history(_ctx(slack_client=client), limit=5) + assert len(out) == 2 + first, second = out + assert first["user"] == "name-U1" + assert first["text"] == "look at this" + assert first["ts"] == "1713.1" + assert first["files"] == [ + { + "name": "cat.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/x/cat.png", + "permalink": "https://slack/p1", + "title": "cute", + } + ] + assert first["reactions"] == [] + + assert second["user"] == "name-U2" + assert second["files"] == [] + assert second["reactions"] == [ + {"emoji": "thumbsup", "count": 2, "users": ["name-U1", "name-U3"]} + ] + + +def test_read_attached_images_accepts_extra_urls(): + """Images referenced from fetch_thread_history (url_private_download) must + be loadable via read_attached_images(urls=[...]).""" + ctx = _ctx() + ctx.llm.describe_image.return_value = "a cat history" + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"fake-bytes" + out = read_attached_images( + ctx, + limit=5, + urls=["https://files.slack.com/x/cat.png"], + ) + assert out == [{"name": "cat.png", "summary": "a cat history"}] + + +def test_read_attached_images_urls_reject_non_slack_host(): + ctx = _ctx() + with pytest.raises(ValueError): + read_attached_images(ctx, urls=["https://evil.example.com/cat.png"]) + + +def test_read_attached_images_respects_total_limit_across_event_and_urls(): + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/e1.png", + "name": "e1.png", + } + ] + } + ctx = _ctx(event=event) + ctx.llm.describe_image.return_value = "desc" + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"x" + out = read_attached_images( + ctx, + limit=2, + urls=[ + "https://files.slack.com/u1.png", + "https://files.slack.com/u2.png", # should be skipped (limit=2) + ], + ) + assert len(out) == 2 + assert {item["name"] for item in out} == {"e1.png", "u1.png"} + + +# --------------------------------------------------------------------------- # +# read_attached_document +# --------------------------------------------------------------------------- # + + +def test_read_attached_document_text_file(): + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/notes.txt", + "name": "notes.txt", + } + ] + } + ctx = _ctx(event=event) + body = b"Hello\n world.\nLine 3." + with patch("src.tools.slack.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.read.return_value = body + resp.headers = {"Content-Length": str(len(body))} + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + entry = out[0] + assert entry["name"] == "notes.txt" + assert entry["mimetype"] == "text/plain" + assert entry["truncated"] is False + assert "Hello" in entry["text"] + assert entry["chars"] == len(entry["text"]) + assert entry["pages"] == 0 # text files report 0 pages + + +def _build_pdf_bytes(pages_text: list[str]) -> bytes: + """Build a minimal PDF (one page per string) using reportlab. Test-only.""" + from io import BytesIO + from reportlab.pdfgen.canvas import Canvas + from reportlab.lib.pagesizes import letter + + buf = BytesIO() + canvas = Canvas(buf, pagesize=letter) + for text in pages_text: + canvas.drawString(72, 720, text) + canvas.showPage() + canvas.save() + return buf.getvalue() + + +def _mock_pdf_response(opener, body: bytes, headers=None): + """Wire the urlopen mock to stream `body` in chunks through `_fetch_slack_file`.""" + resp = opener.return_value.__enter__.return_value + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + resp.read.side_effect = _chunked + resp.headers = dict(headers or {"Content-Length": str(len(body)), "Content-Type": "application/pdf"}) + + +def test_read_attached_document_pdf_happy_path(): + pdf = _build_pdf_bytes(["Hello PDF page one.", "Page two here."]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "name": "report.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + entry = out[0] + assert entry["name"] == "report.pdf" + assert entry["pages"] == 2 + assert entry["truncated"] is False + assert entry["chars"] > 0 + + +def test_read_attached_document_pdf_truncation(): + pdf = _build_pdf_bytes(["A" * 500]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/big.pdf", + "name": "big.pdf", + } + ] + } + ctx = _ctx( + event=event, + ) + ctx = ToolContext( + slack_client=ctx.slack_client, + channel=ctx.channel, + thread_ts=ctx.thread_ts, + event=ctx.event, + settings=_settings(max_doc_chars=50), + llm=ctx.llm, + ) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert out[0]["truncated"] is True + assert out[0]["chars"] == 50 + + +def test_read_attached_document_page_cap(): + pdf = _build_pdf_bytes(["p1", "p2", "p3"]) + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/pages.pdf", + "name": "pages.pdf", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_pages=2), + llm=MagicMock(), + ) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, pdf) + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_PAGES" in out[0]["error"] + + +def test_read_attached_document_size_cap_via_content_length(): + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/huge.txt", + "name": "huge.txt", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_bytes=100), # tiny cap + llm=MagicMock(), + ) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": "200"} # > cap + resp.read.return_value = b"x" * 10 # should never be read past cap + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_BYTES" in out[0]["error"] + + +def test_read_attached_document_size_cap_via_streamed_read(): + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/nohead.txt", + "name": "nohead.txt", + } + ] + } + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event=event, + settings=_settings(max_doc_bytes=100), + llm=MagicMock(), + ) + body = b"y" * 200 + with patch("src.tools.slack.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {} # no Content-Length + buf = {"pos": 0} + + def _chunked(n=-1): + if n == -1: + remaining = body[buf["pos"]:] + buf["pos"] = len(body) + return remaining + chunk = body[buf["pos"]:buf["pos"] + n] + buf["pos"] += len(chunk) + return chunk + + resp.read.side_effect = _chunked + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "MAX_DOC_BYTES" in out[0]["error"] + + +def test_read_attached_document_rejects_non_slack_host(): + ctx = _ctx() + out = read_attached_document( + ctx, urls=["https://evil.example.com/foo.pdf"], limit=1 + ) + assert len(out) == 1 + assert "error" in out[0] + assert "invalid" in out[0]["error"].lower() + + +def test_read_attached_document_skips_encrypted_pdf(): + from io import BytesIO + from pypdf import PdfWriter + + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + # NOTE: pypdf>=4.0 uses keyword-only user_password. If requirements.txt's + # upper pin is ever relaxed past 6.0, verify this signature still holds. + writer.encrypt(user_password="secret") + buf = BytesIO() + writer.write(buf) + encrypted_pdf = buf.getvalue() + + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/enc.pdf", + "name": "enc.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + _mock_pdf_response(opener, encrypted_pdf) + out = read_attached_document(ctx, limit=1) + assert "error" in out[0] + assert "encrypted" in out[0]["error"] + + +def test_read_attached_document_skips_image_mime(): + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/a.png", + "name": "a.png", + } + ] + } + ctx = _ctx(event=event) + # urlopen should NOT be called — image MIMEs are filtered before fetch + with patch("src.tools.slack.urllib.request.urlopen") as opener: + out = read_attached_document(ctx, limit=1) + opener.assert_not_called() + assert out == [] + + +def test_read_attached_document_http_error_returns_per_item(): + import urllib.error + + event = { + "files": [ + { + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/missing.pdf", + "name": "missing.pdf", + } + ] + } + ctx = _ctx(event=event) + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.side_effect = urllib.error.HTTPError( + url="https://files.slack.com/missing.pdf", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + out = read_attached_document(ctx, limit=1) + assert len(out) == 1 + assert "error" in out[0] + assert "404" in out[0]["error"] diff --git a/tests/tools/test_time.py b/tests/tools/test_time.py new file mode 100644 index 0000000..9b137b7 --- /dev/null +++ b/tests/tools/test_time.py @@ -0,0 +1,49 @@ +"""Tests for src.tools.time.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.llms import ToolCall +from src.tools.registry import ToolExecutor +from src.tools import default_registry +from src.tools.time import get_current_time + + +# --------------------------------------------------------------------------- # +# get_current_time +# --------------------------------------------------------------------------- # + + +def test_get_current_time_uses_default_timezone(): + ctx = _ctx() # _settings() default_timezone defaults to Asia/Seoul + out = get_current_time(ctx) + assert out["timezone"] == "Asia/Seoul" + assert out["iso"].endswith("+09:00") + # Weekday is a full English day name (Monday..Sunday) + assert out["weekday"] in { + "Monday", "Tuesday", "Wednesday", "Thursday", + "Friday", "Saturday", "Sunday", + } + assert isinstance(out["unix"], int) + + +def test_get_current_time_respects_custom_timezone(): + ctx = _ctx() + out = get_current_time(ctx, timezone="UTC") + assert out["timezone"] == "UTC" + assert out["iso"].endswith("+00:00") + + +def test_get_current_time_invalid_tz_via_executor(): + """Invalid timezone should surface as {ok: False, error: ...} via the + executor so the LLM can recover.""" + executor = ToolExecutor(_ctx(), default_registry) + result = executor.execute( + ToolCall(id="t1", name="get_current_time", arguments={"timezone": "Narnia/Center"}) + ) + assert result["ok"] is False + assert "unknown timezone" in result["error"] diff --git a/tests/tools/test_web.py b/tests/tools/test_web.py new file mode 100644 index 0000000..9870910 --- /dev/null +++ b/tests/tools/test_web.py @@ -0,0 +1,490 @@ +"""Tests for src.tools.web.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tests.tools._helpers import _ctx, _settings, _streamed_read +from src.config import Settings +from src.llms import ToolCall +from src.tools.registry import ToolContext, ToolExecutor +from src.tools.web import ( + _HtmlTextExtractor, + _NoRedirectHandler, + _extract_markdown_links, + _filter_links, + _jina_fetch, + _parse_jina_response, + _raw_fetch, + _validate_public_https_url, + fetch_webpage, +) + + +def _public_dns(monkeypatch): + """Route all src.tools.web.socket.getaddrinfo lookups to a public IP.""" + + def _public(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("93.184.216.34", port))] + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", _public) + + +# --------------------------------------------------------------------------- # +# fetch_webpage — SSRF guard +# --------------------------------------------------------------------------- # + + +def test_validate_public_https_url_rejects_http_scheme(): + with pytest.raises(ValueError, match="https"): + _validate_public_https_url("http://example.com/") + + +def test_validate_public_https_url_rejects_ip_literal_v4(): + with pytest.raises(ValueError, match="IP literals"): + _validate_public_https_url("https://127.0.0.1/") + + +def test_validate_public_https_url_rejects_ip_literal_v6(): + with pytest.raises(ValueError, match="IP literals"): + _validate_public_https_url("https://[::1]/") + + +def test_validate_public_https_url_rejects_private_dns(monkeypatch): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + # Simulate DNS pointing at RFC1918 space. + return [(None, None, None, "", ("10.0.0.1", port))] + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="non-public"): + _validate_public_https_url("https://internal.corp.example/") + + +def test_validate_public_https_url_rejects_metadata_host(monkeypatch): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("169.254.169.254", port))] + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="non-public"): + _validate_public_https_url("https://cloud.metadata.example/") + + +def test_validate_public_https_url_accepts_public_host(monkeypatch): + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + return [(None, None, None, "", ("93.184.216.34", port))] # example.com + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + scheme, host = _validate_public_https_url("https://example.com/path") + assert scheme == "https" + assert host == "example.com" + + +def test_validate_public_https_url_dns_failure(monkeypatch): + import socket as _socket + + def fake_getaddrinfo(host, port, family=0, type=0, *args, **kwargs): + raise _socket.gaierror("nodename nor servname provided") + + monkeypatch.setattr("src.tools.web.socket.getaddrinfo", fake_getaddrinfo) + with pytest.raises(ValueError, match="DNS resolution failed"): + _validate_public_https_url("https://nonexistent.invalid.example/") + + +def test_no_redirect_handler_raises_on_302(): + import urllib.error + import urllib.request + + handler = _NoRedirectHandler() + req = urllib.request.Request("https://example.com/") + with pytest.raises(urllib.error.HTTPError, match="redirects not allowed"): + handler.redirect_request(req, None, 302, "Found", {}, "https://evil.example/") + + +# --------------------------------------------------------------------------- # +# fetch_webpage — HTML parser + Jina response parser +# --------------------------------------------------------------------------- # + + +def test_html_text_extractor_basic(): + html = ( + " Hello " + "" + "" + "" + "

Heading

" + "

Para one.

" + "

Para two.

" + "Link A" + "Rel" + "Frag" + "Mail" + "" + ) + x = _HtmlTextExtractor("https://base.example/page") + x.feed(html) + assert x.title() == "Hello" + text = x.text() + assert "Heading" in text + assert "Para one." in text + assert "Para two." in text + assert "alert" not in text + assert ".a{}" not in text + assert ("Link A", "https://a.example/1") in x.links + # Relative links resolved against base + assert ("Rel", "https://base.example/relative") in x.links + # mailto / fragment retained raw — filtering happens in _filter_links + assert any(url.startswith("mailto:") for _, url in x.links) + + +def test_filter_links_drops_non_https_and_dedups(): + raw = [ + ("A", "https://a.example/1"), + ("A dup", "https://a.example/1#top"), # dedups by fragment-stripped url + ("B", "https://b.example/"), + ("Self", "https://base.example/page"), # self-ref dropped + ("Mail", "mailto:x@y"), + ("JS", "javascript:void(0)"), + ("HTTP", "http://insecure.example/"), + ] + out = _filter_links(raw, base_url="https://base.example/page", limit=10) + urls = [item["url"] for item in out] + assert urls == ["https://a.example/1", "https://b.example/"] + assert out[0]["title"] == "A" + + +def test_filter_links_respects_limit(): + raw = [(f"T{i}", f"https://x.example/{i}") for i in range(5)] + out = _filter_links(raw, base_url="https://base.example/", limit=3) + assert len(out) == 3 + assert [item["url"] for item in out] == [ + "https://x.example/0", + "https://x.example/1", + "https://x.example/2", + ] + + +def test_extract_markdown_links_parses_inline_markdown(): + md = ( + "Title\n\nSome prose with [Google](https://google.com/about) " + "and [Self](https://base.example/page) and [Same](https://google.com/about?ref=x).\n" + "[Another](https://example.org/)" + ) + out = _extract_markdown_links(md, base_url="https://base.example/page", limit=10) + urls = [item["url"] for item in out] + assert "https://google.com/about" in urls + assert "https://example.org/" in urls + assert "https://base.example/page" not in urls # self-ref dropped + + +def test_parse_jina_response_strips_header(): + payload = ( + "Title: Example Page\n" + "URL Source: https://example.com/\n" + "Markdown Content:\n" + "# Heading\n\nBody text.\n" + ) + title, body = _parse_jina_response(payload) + assert title == "Example Page" + assert body.startswith("# Heading") + assert "URL Source" not in body + + +def test_parse_jina_response_no_header(): + payload = "just raw markdown\n\nwith no prefix" + title, body = _parse_jina_response(payload) + assert title == "" + assert body == payload + + +def test_html_text_extractor_empty(): + x = _HtmlTextExtractor("https://base.example/") + x.feed("") + assert x.title() == "" + assert x.text() == "" + assert x.links == [] + + +def test_filter_links_host_case_normalization(): + out = _filter_links( + [ + ("A", "https://Example.COM/path"), + ("B", "https://example.com/path"), # dup by host case + ("Self", "https://BASE.EXAMPLE/page"), # self-ref by host case + ], + base_url="https://base.example/page", + limit=10, + ) + urls = [item["url"] for item in out] + assert urls == ["https://Example.COM/path"] # first-seen wins, case preserved in output + + +def test_extract_markdown_links_skips_images(): + md = ( + "![logo](https://img.example/logo.png) " + "see [Docs](https://docs.example/) for more" + ) + out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) + urls = [item["url"] for item in out] + assert "https://img.example/logo.png" not in urls + assert "https://docs.example/" in urls + + +def test_extract_markdown_links_preserves_paren_in_url(): + md = "see [Wiki](https://en.wikipedia.org/wiki/Foo_(bar)) for context" + out = _extract_markdown_links(md, base_url="https://base.example/", limit=10) + urls = [item["url"] for item in out] + assert "https://en.wikipedia.org/wiki/Foo_(bar)" in urls + + +def test_parse_jina_response_inline_markdown_content(): + payload = "Title: T\nURL Source: https://example.com/\nMarkdown Content: # Heading\n\nBody here." + title, body = _parse_jina_response(payload) + assert title == "T" + assert body.startswith("# Heading") + assert "Body here." in body + + +# --------------------------------------------------------------------------- # +# fetch_webpage — fetch helpers +# --------------------------------------------------------------------------- # + + +def test_jina_fetch_returns_body_under_cap(): + payload = b"Title: x\nURL Source: https://example.com/\n\nBody here." + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(payload)), "Content-Type": "text/markdown"} + resp.read.side_effect = _streamed_read(payload) + text = _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + assert "Body here." in text + + +def test_jina_fetch_content_length_over_cap(): + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": "999999"} + resp.read.side_effect = _streamed_read(b"x" * 10) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + + +def test_jina_fetch_streamed_over_cap(): + body = b"x" * 2000 + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {} # no Content-Length + resp.read.side_effect = _streamed_read(body) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _jina_fetch("https://r.jina.ai", "https://example.com/", max_bytes=1024) + + +def test_raw_fetch_returns_body_under_cap(monkeypatch): + body = b"Hihello" + + # _raw_fetch builds its own opener (with _NoRedirectHandler) and calls opener.open. + # Patch urllib.request.build_opener so we intercept that call. + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(body)), "Content-Type": "text/html"} + cm.read.side_effect = _streamed_read(body) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + + html = _raw_fetch("https://example.com/", max_bytes=1024) + assert "hello" in html + + +def test_raw_fetch_streamed_over_cap(monkeypatch): + body = b"a" * 4096 + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {} + cm.read.side_effect = _streamed_read(body) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + with pytest.raises(ValueError, match="MAX_WEB_BYTES"): + _raw_fetch("https://example.com/", max_bytes=1024) + + +# --------------------------------------------------------------------------- # +# fetch_webpage — end-to-end via tool function +# --------------------------------------------------------------------------- # + + +def test_fetch_webpage_rejects_http_via_tool(monkeypatch): + ctx = _ctx() + with pytest.raises(ValueError, match="https"): + fetch_webpage(ctx, url="http://example.com/") + + +def test_fetch_webpage_jina_happy_path(monkeypatch): + _public_dns(monkeypatch) + jina_body = ( + b"Title: Example Page\n" + b"URL Source: https://example.com/\n" + b"Markdown Content:\n" + b"# Hello\n\nSee [Docs](https://docs.example.com/) and [Blog](https://blog.example.com/).\n" + ) + ctx = _ctx() + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(jina_body))} + resp.read.side_effect = _streamed_read(jina_body) + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "jina" + assert out["title"] == "Example Page" + assert "Hello" in out["content"] + urls = [link["url"] for link in out["links"]] + assert "https://docs.example.com/" in urls + assert "https://blog.example.com/" in urls + assert out["truncated"] is False + assert out["chars"] == len(out["content"]) + + +def test_fetch_webpage_falls_back_to_raw_on_jina_5xx(monkeypatch): + import urllib.error + + _public_dns(monkeypatch) + html_body = ( + b"Raw Title" + b"

Raw body.

" + b"Docs" + ) + + def jina_fail(*args, **kwargs): + raise urllib.error.HTTPError( + "https://r.jina.ai/https://example.com/", 500, "boom", {}, None + ) + + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(html_body))} + cm.read.side_effect = _streamed_read(html_body) + + monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_fail) + monkeypatch.setattr("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + + ctx = _ctx() + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "raw" + assert out["title"] == "Raw Title" + assert "Raw body." in out["content"] + assert any(link["url"] == "https://docs.example.com/" for link in out["links"]) + + +def test_fetch_webpage_jina_body_over_cap_falls_back_to_raw(monkeypatch): + """Jina oversize → fall through to raw fetch instead of raising.""" + _public_dns(monkeypatch) + settings = Settings( + slack_bot_token="xoxb-test", + slack_signing_secret="sig", + llm_provider="openai", + llm_model="gpt-4o-mini", + image_provider="openai", + image_model="gpt-image-1", + agent_max_steps=3, + response_language="ko", + dynamodb_table_name="t", + aws_region="us-east-1", + max_web_bytes=128, + max_web_chars=8000, + max_web_links=20, + jina_reader_base="https://r.jina.ai", + ) + ctx = ToolContext( + slack_client=MagicMock(), + channel="C1", + thread_ts="ts1", + event={}, + settings=settings, + llm=MagicMock(), + ) + + huge_jina = b"x" * 4096 + raw_html = b"

raw small body

" + + fake_opener = MagicMock() + cm = fake_opener.open.return_value.__enter__.return_value + cm.headers = {"Content-Length": str(len(raw_html))} + cm.read.side_effect = _streamed_read(raw_html) + + with patch("src.tools.web.urllib.request.urlopen") as jina_open: + jresp = jina_open.return_value.__enter__.return_value + jresp.headers = {} # no Content-Length → streamed-read path + jresp.read.side_effect = _streamed_read(huge_jina) + monkeypatch_build = patch("src.tools.web.urllib.request.build_opener", lambda *_: fake_opener) + with monkeypatch_build: + out = fetch_webpage(ctx, url="https://example.com/") + assert out["source"] == "raw" + assert "raw small body" in out["content"] + + +def test_fetch_webpage_max_chars_truncates(monkeypatch): + _public_dns(monkeypatch) + long_body = b"Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + (b"A" * 500) + ctx = _ctx() + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(long_body))} + resp.read.side_effect = _streamed_read(long_body) + out = fetch_webpage(ctx, url="https://example.com/", max_chars=200) + assert out["truncated"] is True + assert out["chars"] == 200 + assert len(out["content"]) == 200 + + +def test_fetch_webpage_both_paths_fail_raises(monkeypatch): + import urllib.error + + _public_dns(monkeypatch) + + def jina_500(*args, **kwargs): + raise urllib.error.HTTPError( + "https://r.jina.ai/...", 500, "jina boom", {}, None + ) + + def raw_503(*args, **kwargs): + raise urllib.error.HTTPError( + "https://example.com/", 503, "raw boom", {}, None + ) + + fake_opener = MagicMock() + fake_opener.open.side_effect = raw_503 + + monkeypatch.setattr("src.tools.web.urllib.request.urlopen", jina_500) + monkeypatch.setattr( + "src.tools.web.urllib.request.build_opener", lambda *_: fake_opener + ) + + ctx = _ctx() + with pytest.raises(ValueError, match=r"jina=.*raw="): + fetch_webpage(ctx, url="https://example.com/") + + +def test_fetch_webpage_max_links_dedup(monkeypatch): + _public_dns(monkeypatch) + link_section = ( + "[a](https://a.example/)" + "[b](https://b.example/)" + "[c](https://c.example/)" + "[d](https://d.example/)" + "[dup-a](https://a.example/)" + "[e](https://e.example/)" + "[dup-b](https://b.example/)" + "[f](https://f.example/)" + ) + payload = ( + "Title: T\nURL Source: https://example.com/\nMarkdown Content:\n" + link_section + ).encode() + ctx = _ctx() + with patch("src.tools.web.urllib.request.urlopen") as opener: + resp = opener.return_value.__enter__.return_value + resp.headers = {"Content-Length": str(len(payload))} + resp.read.side_effect = _streamed_read(payload) + out = fetch_webpage(ctx, url="https://example.com/", max_links=3) + urls = [link["url"] for link in out["links"]] + assert urls == [ + "https://a.example/", + "https://b.example/", + "https://c.example/", + ] From 1e2e2f99a4b8a5b889a9e6ab5911ddb13cdf1b15 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:12:36 +0900 Subject: [PATCH 34/65] refactor(tests): drop unused imports in tests/tools after split --- tests/tools/test_image.py | 7 ++----- tests/tools/test_registry.py | 7 +------ tests/tools/test_search.py | 4 +--- tests/tools/test_slack.py | 4 +--- tests/tools/test_time.py | 7 +------ tests/tools/test_web.py | 2 -- 6 files changed, 6 insertions(+), 25 deletions(-) diff --git a/tests/tools/test_image.py b/tests/tools/test_image.py index e6f03f2..9b02fff 100644 --- a/tests/tools/test_image.py +++ b/tests/tools/test_image.py @@ -1,12 +1,9 @@ """Tests for src.tools.image.""" from __future__ import annotations -import json -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import pytest - -from tests.tools._helpers import _ctx, _settings, _streamed_read +from tests.tools._helpers import _ctx from src.tools.image import generate_image diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index f22615e..fc28ec6 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -1,12 +1,7 @@ """Tests for src.tools.registry.""" from __future__ import annotations -import json -from unittest.mock import MagicMock, patch - -import pytest - -from tests.tools._helpers import _ctx, _settings, _streamed_read +from tests.tools._helpers import _ctx from src.llms import ToolCall from src.tools import default_registry from src.tools.registry import ToolDef, ToolExecutor, ToolRegistry diff --git a/tests/tools/test_search.py b/tests/tools/test_search.py index dcf47ab..65f51eb 100644 --- a/tests/tools/test_search.py +++ b/tests/tools/test_search.py @@ -4,9 +4,7 @@ import json from unittest.mock import MagicMock, patch -import pytest - -from tests.tools._helpers import _ctx, _settings, _streamed_read +from tests.tools._helpers import _ctx, _settings from src.tools.registry import ToolContext from src.tools.search import search_web diff --git a/tests/tools/test_slack.py b/tests/tools/test_slack.py index 7dc70cb..9a4c731 100644 --- a/tests/tools/test_slack.py +++ b/tests/tools/test_slack.py @@ -1,13 +1,11 @@ """Tests for src.tools.slack.""" from __future__ import annotations -import json from unittest.mock import MagicMock, patch import pytest -from tests.tools._helpers import _ctx, _settings, _streamed_read -from src.llms import ToolCall +from tests.tools._helpers import _ctx, _settings from src.tools.registry import ToolContext, ToolExecutor from src.tools.slack import ( fetch_thread_history, diff --git a/tests/tools/test_time.py b/tests/tools/test_time.py index 9b137b7..fc6b315 100644 --- a/tests/tools/test_time.py +++ b/tests/tools/test_time.py @@ -1,12 +1,7 @@ """Tests for src.tools.time.""" from __future__ import annotations -import json -from unittest.mock import MagicMock, patch - -import pytest - -from tests.tools._helpers import _ctx, _settings, _streamed_read +from tests.tools._helpers import _ctx from src.llms import ToolCall from src.tools.registry import ToolExecutor from src.tools import default_registry diff --git a/tests/tools/test_web.py b/tests/tools/test_web.py index 9870910..6b67593 100644 --- a/tests/tools/test_web.py +++ b/tests/tools/test_web.py @@ -1,14 +1,12 @@ """Tests for src.tools.web.""" from __future__ import annotations -import json from unittest.mock import MagicMock, patch import pytest from tests.tools._helpers import _ctx, _settings, _streamed_read from src.config import Settings -from src.llms import ToolCall from src.tools.registry import ToolContext, ToolExecutor from src.tools.web import ( _HtmlTextExtractor, From 92294bf4bc92da32404c4c138b0dd8befed321bb Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:14:01 +0900 Subject: [PATCH 35/65] docs: update module paths and document extension points --- CLAUDE.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cbfd72..e804867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ and diverges from native function-calling semantics. ### Agent loop uses NATIVE function calling, not JSON prompting -`src/agent.py` passes `registry.specs()` directly to `LLMProvider.chat(tools=...)`. The provider (`src/llm.py`) translates that to OpenAI `tools=[{type:"function",function:{...}}]` or Bedrock `tools=[{name, description, input_schema}]` (Claude) / `toolConfig` (Nova). There is **no JSON-in-prompt parsing** — tool calls arrive as structured objects. Loop terminates when `stop_reason != "tool_use"` or `max_steps` hit. On max_steps, a forced compose step (`_compose_without_tools`) runs with `tools=None`. +`src/agent.py` passes `registry.specs()` directly to `LLMProvider.chat(tools=...)`. The provider (`src/llms/`) translates that to OpenAI `tools=[{type:"function",function:{...}}]` or Bedrock `tools=[{name, description, input_schema}]` (Claude) / `toolConfig` (Nova). There is **no JSON-in-prompt parsing** — tool calls arrive as structured objects. Loop terminates when `stop_reason != "tool_use"` or `max_steps` hit. On max_steps, a forced compose step (`_compose_without_tools`) runs with `tools=None`. Duplicate tool-call suppression: `_call_signature` = `name + sha1(args_json)`. A repeated signature within the loop is short-circuited with `{"ok": False, "error": "duplicate call skipped"}` and handed back to the LLM so it can move on. @@ -108,7 +108,7 @@ Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; ### Public web fetching is SSRF-gated -`fetch_webpage` uses `_validate_public_https_url` (in `src/tools_web.py`) to enforce `https`, reject IP literals, and drop DNS results resolving to any non-public address (private / loopback / link-local / reserved / multicast / unspecified / non-global — CGNAT `100.64.0.0/10` included). The Jina Reader path (`{JINA_READER_BASE}/{percent-encoded url}`) does the actual network hop against the target; the raw fallback (and only the raw fallback, since the Jina path uses Jina's own fetch) goes direct with a `_NoRedirectHandler` that refuses 3xx, so a redirect into RFC1918 space can't slip past the pre-flight DNS check. Body size is capped by `MAX_WEB_BYTES` on both paths; if Jina exceeds the cap we fall through to raw (the direct fetch may be smaller than Jina's markdown-ified output). Web helpers live in `src/tools_web.py` and are re-exported from `src/tools.py` for callers and test imports. +`fetch_webpage` uses `_validate_public_https_url` (in `src/tools/web.py`) to enforce `https`, reject IP literals, and drop DNS results resolving to any non-public address (private / loopback / link-local / reserved / multicast / unspecified / non-global — CGNAT `100.64.0.0/10` included). The Jina Reader path (`{JINA_READER_BASE}/{percent-encoded url}`) does the actual network hop against the target; the raw fallback (and only the raw fallback, since the Jina path uses Jina's own fetch) goes direct with a `_NoRedirectHandler` that refuses 3xx, so a redirect into RFC1918 space can't slip past the pre-flight DNS check. Body size is capped by `MAX_WEB_BYTES` on both paths; if Jina exceeds the cap we fall through to raw (the direct fetch may be smaller than Jina's markdown-ified output). Web helpers live in `src/tools/web.py`. Every tool submodule registers itself into `default_registry` at import time; `src/tools/__init__.py` imports the submodules so importing the package is enough to make every built-in tool available. ### Config is lazy, not import-time @@ -126,6 +126,14 @@ Stream throttling is handled inside `StreamingMessage.append()` (`min_interval=0 `src/logging_utils.py` installs a JSON handler on root. `set_request_id(uuid)` is called at the start of each `_process`. `log_event(logger, "agent.done", steps=..., tokens_in=...)` emits records whose `extra_fields` dict survives into the JSON payload — useful for CloudWatch Insights queries. Because `logging.LoggerAdapter.process()` in Python 3.12 overwrites `extra=`, `log_event` dispatches via `logger.logger` (the underlying `Logger`) instead of the adapter. +### Extension points + +**Add a new tool.** Create `src/tools/.py` with one or more functions decorated by `@tool(default_registry, name="...", description="...", parameters={...})`. Add `` to the side-effect import block in `src/tools/__init__.py`. Add `tests/tools/test_.py`. That's it — the agent loop sees the new tool because `default_registry` is populated at import time. + +**Add a new LLM provider.** Create `src/llms/.py` with a class that satisfies the `LLMProvider` Protocol (`chat`, `stream_chat`, `describe_image`, `generate_image`). Add a branch to `src/llms/factory.py`'s `get_llm`, and if the provider introduces new model families extend `_VALID_PROVIDERS` in `src/config.py`. Add `tests/llms/test_.py`. + +Neither extension requires editing the registry or the agent loop. + ## Deployment `serverless.yml` provisions: From e3bca2719c04d707fbdbf3897edc181f32c7d701 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:25:24 +0900 Subject: [PATCH 36/65] refactor(tests): drop empty llms test stubs --- tests/llms/test_base.py | 2 -- tests/llms/test_openai_wire.py | 2 -- 2 files changed, 4 deletions(-) delete mode 100644 tests/llms/test_base.py delete mode 100644 tests/llms/test_openai_wire.py diff --git a/tests/llms/test_base.py b/tests/llms/test_base.py deleted file mode 100644 index 219810b..0000000 --- a/tests/llms/test_base.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Tests for src.llms.base.""" -from __future__ import annotations diff --git a/tests/llms/test_openai_wire.py b/tests/llms/test_openai_wire.py deleted file mode 100644 index b31f380..0000000 --- a/tests/llms/test_openai_wire.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Tests for src.llms.openai_wire.""" -from __future__ import annotations From fd30c99208ed867a3c2bfd5042726a12154b54ea Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:31:28 +0900 Subject: [PATCH 37/65] docs: refresh README and add extension guide --- README.md | 50 ++++++++- docs/extending.md | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 docs/extending.md diff --git a/README.md b/README.md index 995183f..2125025 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA - **Provider**: OpenAI · AWS Bedrock(Anthropic Claude 3/3.5/4.x · Amazon Nova) · xAI(Grok) 선택 가능 - **Tools (네이티브 function calling)** - `read_attached_images` — 첨부 이미지 Vision 요약 + - `read_attached_document` — 첨부 PDF/텍스트 파일 추출 (페이지·바이트·문자 상한 적용) - `fetch_thread_history` — 스레드 히스토리 조회 - `search_web` — Tavily (TAVILY_API_KEY 설정 시) 또는 DuckDuckGo + - `fetch_webpage` — 공개 HTTPS 웹 페이지 본문·링크 추출 (Jina Reader 우선 + raw fallback, SSRF 가드) - `generate_image` — 이미지 생성 후 Slack 업로드 - `get_current_time` — 서버 기본 TZ(또는 `timezone` 인자) 로 현재 시각/요일 반환 - - `read_attached_document` — 첨부 PDF/텍스트 파일 추출 (페이지·바이트·문자 상한 적용) - **Production 기반** - DynamoDB 조건부 put 으로 Slack 재시도 **중복 제거** - 채널 allowlist · 유저당 동시 요청 **throttle** @@ -67,6 +68,10 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA | `MAX_DOC_CHARS` | | `20000` | `read_attached_document` 추출 텍스트 최대 문자수 (≥1000) | | `MAX_DOC_PAGES` | | `50` | `read_attached_document` PDF 최대 페이지수 (≥1) | | `MAX_DOC_BYTES` | | `26214400` | `read_attached_document` 다운로드 최대 바이트 (기본 25MB, ≥65536) | +| `MAX_WEB_CHARS` | | `8000` | `fetch_webpage` 반환 본문 최대 문자수 (≥500) | +| `MAX_WEB_BYTES` | | `2097152` | `fetch_webpage` 다운로드 최대 바이트 (기본 2MB, ≥65536) | +| `MAX_WEB_LINKS` | | `20` | `fetch_webpage` 반환 링크 최대 개수 (≥0) | +| `JINA_READER_BASE` | | `https://r.jina.ai` | `fetch_webpage` 가 호출하는 Jina Reader 베이스 URL. `https://` 가 아니면 기본값으로 폴백 | | `BOT_CURSOR` | | `:robot_face:` | 플레이스홀더·스트림 인디케이터 이모지 | | `SYSTEM_MESSAGE` | | — | 시스템 프롬프트 오버라이드 | | `LOG_LEVEL` | | `INFO` | 로그 레벨 | @@ -96,9 +101,10 @@ python localtest.py --no-stream "React 훅 설명해줘" # 전체 답변을 python localtest.py --quiet-steps "…" # 중간 step 로그 숨김 python localtest.py # 대화형 (stdin, Ctrl+D) -# 테스트 (125 테스트, 커버리지 86% — `pytest.ini` 기준) +# 테스트 (161 테스트, 커버리지 89% — `pytest.ini` 기준) python -m pytest --cov=src --cov-report=term-missing -python -m pytest tests/test_agent.py::test_agent_runs_tool_then_returns_text -v # 단일 케이스 +python -m pytest tests/llms/test_bedrock.py -v # 패키지 단위 +python -m pytest tests/tools/test_web.py::test_fetch_webpage_jina_happy_path -v # 단일 케이스 ``` `.env.local` 은 `src/config.py` 가 python-dotenv 로 자동 로드합니다. `SLACK_BOT_TOKEN` 이 placeholder 이면 `localtest.py` 가 Slack 호출을 stub 으로 대체하고 `generate_image` 결과물은 `./.uploads/` 에 파일로 저장됩니다. @@ -144,6 +150,44 @@ DynamoDB 테이블 (해시키 `id`, GSI `user-index`, TTL `expire_at`) 은 Cloud | `sync-notion.yml` | Notion → S3 → Bedrock KB ingestion | `workflow_dispatch` 전용, `vars.ENABLE_SYNC_NOTION == 'true'` gating. **Phase 2 미완성** (아래 참조) | | `sync-awsdocs.yml` | AWS 공식 문서 → S3 → KB ingestion | 위와 동일 패턴, `ENABLE_SYNC_AWSDOCS` gating | +## 코드 구조 + +``` +app.py Lambda 엔트리 · Slack Bolt 핸들러 · `_process()` 흐름 +src/ +├── agent.py Agent 루프 (native function calling 반복) +├── config.py Settings (env → dataclass, lazy validation) +├── dedup.py DynamoDB 기반 중복 제거 / 대화 메모리 +├── logging_utils.py 구조화 JSON 로깅 + request_id +├── slack_helpers.py 메시지 분할·스트리밍·사용자 캐시 +├── llms/ LLM provider 패키지 +│ ├── base.py Protocol + 공통 타입 + _with_retry +│ ├── openai_wire.py OpenAI wire 공통 (OpenAI·xAI 공유) +│ ├── openai.py OpenAIProvider +│ ├── xai.py XAIProvider +│ ├── bedrock.py BedrockProvider (Anthropic·Nova·Stability) +│ ├── composite.py _CompositeProvider (text+image 분리 설정) +│ └── factory.py get_llm +└── tools/ Tool 패키지 + ├── registry.py ToolDef · ToolRegistry · @tool · ToolExecutor + ├── slack.py read_attached_images · read_attached_document · fetch_thread_history + ├── search.py search_web (DuckDuckGo / Tavily) + ├── web.py fetch_webpage + SSRF 가드 + HTML/Jina 파서 + ├── image.py generate_image + └── time.py get_current_time +``` + +테스트는 소스 구조를 그대로 미러링한 `tests/llms/`, `tests/tools/` 에 있습니다. + +## 확장하기 + +새로운 tool 이나 LLM provider 는 파일 하나를 추가하는 것으로 끝납니다. 자세한 단계는 [`docs/extending.md`](docs/extending.md) 를 참고하세요. + +짧게 말해: + +- **새 tool**: `src/tools/.py` 에 `@tool(default_registry, ...)` 로 데코레이트된 함수를 정의하고, `src/tools/__init__.py` 의 side-effect import 블록에 이름을 추가하면 `default_registry` 가 자동으로 등록합니다. +- **새 LLM provider**: `src/llms/.py` 에 `LLMProvider` Protocol 을 만족하는 클래스를 작성하고 `src/llms/factory.py` 의 `get_llm` 분기에 연결합니다. + ## 아키텍처 ``` diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..d8b6745 --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,259 @@ +# 확장 가이드 + +새 tool 또는 새 LLM provider 를 추가하는 방법을 step-by-step 으로 설명합니다. 둘 다 "파일 하나 추가 + `__init__.py` 한 줄" 수준으로 끝납니다 — agent 루프나 레지스트리 코드는 건드리지 않습니다. + +--- + +## 새 tool 추가 + +Tool 은 LLM 이 native function calling 으로 호출하는 Python 함수입니다. `@tool(...)` 데코레이터가 JSON Schema · 레지스트리 등록 · dispatch 테이블을 한 번에 처리합니다. + +### 1. 파일 만들기 + +예: 랜덤 정수를 반환하는 `random_int` tool. + +`src/tools/random_int.py` + +```python +"""Random integer tool — demonstrates the minimum shape of a new tool.""" +from __future__ import annotations + +import random +from typing import Any + +from src.tools.registry import ToolContext, default_registry, tool + + +@tool( + default_registry, + name="random_int", + description="Return a random integer in the inclusive range [low, high].", + parameters={ + "type": "object", + "properties": { + "low": {"type": "integer"}, + "high": {"type": "integer"}, + }, + "required": ["low", "high"], + }, +) +def random_int(ctx: ToolContext, low: int, high: int) -> dict[str, int]: + if low > high: + raise ValueError("low must be <= high") + return {"value": random.randint(low, high)} +``` + +**규칙** + +- 첫 번째 인자는 항상 `ctx: ToolContext` — LLM 에는 노출되지 않는 런타임 컨텍스트 (`slack_client`, `channel`, `thread_ts`, `event`, `settings`, `llm`). +- 나머지 인자는 `parameters` JSON Schema 가 그대로 정의. 키워드 전달되므로 LLM 이 부르는 argument 이름과 정확히 일치시킬 것. +- 반환 타입은 JSON 직렬화 가능해야 합니다 (`dict`, `list`, `str`, `int`, `float`, `bool`). Agent 루프가 이걸 `role=tool` 메시지로 다시 LLM 에 넘겨줍니다. +- 에러 처리: 복구 불가능한 실패는 `ValueError` 로 raise 합니다. `ToolExecutor` 가 잡아서 `{"ok": False, "error": "..."}` 로 감싸 LLM 이 상황을 보고 재계획할 수 있게 해줍니다. +- 외부 네트워크 I/O 가 있으면 host allowlist 또는 `_validate_public_https_url` 같은 SSRF 가드를 반드시 적용할 것 (`src/tools/web.py` 참고). + +### 2. `__init__.py` 에 등록 + +`src/tools/__init__.py` 의 side-effect import 블록에 새 모듈 이름을 추가합니다. + +```python +from . import ( # noqa: F401 (imported for side effects) + image, + random_int, # ← 추가 + search, + slack, + time, + web, +) +``` + +`default_registry` 는 module import 시점에 데코레이터 호출로 채워지므로, 이 import 한 줄로 agent 가 새 tool 을 자동 인식합니다. + +### 3. 테스트 작성 + +`tests/tools/test_random_int.py` + +```python +"""Tests for src.tools.random_int.""" +from __future__ import annotations + +import pytest + +from tests.tools._helpers import _ctx +from src.tools.random_int import random_int + + +def test_random_int_returns_value_in_range(): + ctx = _ctx() + for _ in range(20): + out = random_int(ctx, low=1, high=3) + assert out["value"] in {1, 2, 3} + + +def test_random_int_rejects_inverted_range(): + ctx = _ctx() + with pytest.raises(ValueError, match="low must be"): + random_int(ctx, low=5, high=1) +``` + +공용 fixture (`_ctx`, `_settings`, `_streamed_read`) 는 `tests/tools/_helpers.py` 에서 import 하세요 — 각 테스트 파일에 중복 정의하지 않습니다. + +### 4. `default_registry` 등록 검증 + +`tests/tools/test_registry.py` 의 `test_default_registry_has_expected_tools` 가 새 tool 이름을 assert 하도록 업데이트: + +```python +def test_default_registry_has_expected_tools(): + names = set(default_registry.names()) + assert "random_int" in names # ← 추가 +``` + +### 5. (선택) 환경 변수 / 설정 + +새 tool 이 설정값을 읽어야 하면 `src/config.py` 의 `Settings` 에 필드를 추가하고 `from_env()` 에서 env 를 읽어오세요. 기본값과 최소값 검증은 `_int_env`, `_tz_env`, `_enum_env`, `_https_url_env` 헬퍼 패턴을 따릅니다. `.env.example` 에도 새 변수를 예시와 함께 추가하세요. + +### 6. 문서 업데이트 + +- `README.md` 의 "Tools" 목록과 "환경 변수" 표에 추가. +- `CLAUDE.md` 에 비자명한 동작(SSRF 가드 · 동기화 이슈 · 외부 서비스 의존) 이 있으면 "Architecture — the non-obvious parts" 에 짧게 노트. + +--- + +## 새 LLM provider 추가 + +Provider 는 `LLMProvider` Protocol 을 구현하는 클래스입니다. 텍스트 chat · streaming chat · 이미지 describe · 이미지 generate 네 메서드만 만족하면 됩니다. + +### 1. 파일 만들기 + +예: 가상의 `MistralProvider`. + +`src/llms/mistral.py` + +```python +"""MistralProvider — OpenAI-wire compatible via https://api.mistral.ai/v1.""" +from __future__ import annotations + +from typing import Any + +from src.llms.openai_wire import _OpenAICompatProvider + + +class MistralProvider(_OpenAICompatProvider): + """Mistral 의 chat completions API 는 OpenAI wire 와 호환되므로 + `_OpenAICompatProvider` 를 재사용합니다 (xAI 와 같은 패턴).""" + + def __init__(self, model: str, api_key: str, max_output_tokens: int): + super().__init__( + model=model, + api_key=api_key, + max_output_tokens=max_output_tokens, + base_url="https://api.mistral.ai/v1", + ) + + # Mistral 고유의 request 변형이 있으면 override. + # 없으면 base class 로 충분. +``` + +OpenAI wire 호환이 아닌 provider (예: Bedrock 처럼 고유 SDK) 는 `LLMProvider` Protocol 네 메서드를 직접 구현해야 합니다. `src/llms/bedrock.py` 가 좋은 reference 입니다. + +### 2. `factory.py` 에 분기 추가 + +`src/llms/factory.py` + +```python +from src.llms.mistral import MistralProvider # ← 추가 + + +def get_llm( + provider: str, + model: str, + image_provider: str, + image_model: str, + max_output_tokens: int, + openai_api_key: str | None = None, + xai_api_key: str | None = None, + mistral_api_key: str | None = None, # ← 추가 +) -> LLMProvider: + ... + if provider == "mistral": + if not mistral_api_key: + raise RuntimeError("MISTRAL_API_KEY required for LLM_PROVIDER=mistral") + text_llm = MistralProvider(model, mistral_api_key, max_output_tokens) + elif provider == "openai": + ... +``` + +### 3. Settings 에 API key 필드 + +`src/config.py` + +```python +@dataclass(frozen=True) +class Settings: + ... + mistral_api_key: str | None = None + + @classmethod + def from_env(cls) -> "Settings": + ... + mistral_key = os.getenv("MISTRAL_API_KEY", "").strip() or None + return cls( + ..., + mistral_api_key=mistral_key, + ) +``` + +### 4. `_VALID_PROVIDERS` 확장 + +같은 파일의 enum 상수에 `"mistral"` 을 추가합니다. 그래야 `LLM_PROVIDER=mistral` 이 enum 검증을 통과합니다. + +```python +_VALID_PROVIDERS = {"openai", "bedrock", "xai", "mistral"} +``` + +### 5. `get_llm` 호출부 업데이트 + +`app.py` 와 `localtest.py` 가 `Settings` 를 `get_llm` 으로 풀어서 전달하는 부분에 `mistral_api_key=settings.mistral_api_key` 를 추가합니다. + +### 6. 테스트 작성 + +`tests/llms/test_mistral.py` + +```python +"""Tests for src.llms.mistral.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from src.llms.mistral import MistralProvider + + +def test_mistral_chat_uses_mistral_base_url(): + provider = MistralProvider( + model="mistral-large", + api_key="test-key", + max_output_tokens=256, + ) + assert provider.client.base_url.startswith("https://api.mistral.ai/") +``` + +OpenAI-wire 호환 provider 는 `_OpenAICompatProvider` 테스트 패턴을 참고 (`tests/llms/test_xai.py` 가 좋은 예시). + +### 7. 문서 · `.env.example` 업데이트 + +- `.env.example` 에 `MISTRAL_API_KEY=""` 추가. +- `README.md` 의 "환경 변수" 표 · "모델 매트릭스" 에 Mistral 컬럼 또는 행 추가. + +--- + +## 공통 체크리스트 + +작업이 끝났다 싶을 때 한 번 확인: + +- [ ] 전체 테스트 통과 (`python -m pytest`). +- [ ] `python -c "from src.tools import default_registry; print(sorted(default_registry.names()))"` 에 새 tool 이름이 나타남. +- [ ] (provider 추가 시) `python -c "from src.config import Settings; from src.llms import get_llm; get_llm(provider='', ...)"` 가 에러 없이 객체를 반환. +- [ ] `.env.example` 에 새 env 가 반영됨. +- [ ] `README.md` 의 tool/provider 목록과 환경 변수 표가 업데이트됨. + +이 패턴은 agent 루프 (`src/agent.py`), 레지스트리 내부 (`src/tools/registry.py`), 기존 provider/tool 의 코드를 건드리지 않도록 설계됐습니다. 그 파일들을 열어야 하는 변경은 확장 범위를 벗어난 것이므로 별도 논의/설계가 필요합니다. From a316f8e84de13a8bccb63216d0fd08edd65b2b51 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:35:05 +0900 Subject: [PATCH 38/65] fix: add newline before response indication in step output --- localtest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localtest.py b/localtest.py index b205032..73219d9 100644 --- a/localtest.py +++ b/localtest.py @@ -83,7 +83,7 @@ def on_step(step: int, phase: str, detail: dict[str, Any]) -> None: print(f"[step {step}] {mark} {tool}: {err}", file=sys.stderr, flush=True) elif phase == "compose": hint = " (max_steps 도달)" if detail.get("max_steps_hit") else "" - print(f"[step {step}] ▶ 답변 작성 중...{hint}", file=sys.stderr, flush=True) + print(f"\n[step {step}] ▶ 답변 작성 중...{hint}", file=sys.stderr, flush=True) return on_step From ef8e318fb9e71491b4ff37664303266009bf48ae Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:48:35 +0900 Subject: [PATCH 39/65] docs: sync CLAUDE.md + extending guide with current code --- CLAUDE.md | 14 +++++-- docs/extending.md | 96 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e804867..16ecd05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,8 @@ python localtest.py # interactive stdin (Ctrl+D) python -m pytest python -m pytest --cov=src --cov-report=term-missing python -m pytest tests/test_agent.py::test_agent_runs_tool_then_returns_text -v +python -m pytest tests/llms/test_bedrock.py -v # LLM provider unit tests +python -m pytest tests/tools/test_web.py -v # fetch_webpage + SSRF guard # Deploy (requires IAM OIDC role `lambda-gurumi-bot`) npm i -g serverless@3 @@ -154,16 +156,22 @@ Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:aws ## Testing -125 tests, 86% overall coverage. `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: +161 tests, 89% overall coverage. `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: +- Tests mirror source layout: `tests/llms/` for each `src/llms/*` submodule, `tests/tools/` for each `src/tools/*` submodule. Top-level `tests/test_agent.py`, `test_config.py`, `test_dedup.py`, `test_logging_utils.py`, `test_slack_helpers.py` cover the non-packaged modules. +- Shared tool-test fixtures (`_ctx`, `_settings`, `_streamed_read`) live in `tests/tools/_helpers.py` — individual test files import from there instead of redefining them. - `moto[dynamodb]` for `DedupStore` / `ConversationStore` integration tests. -- `responses` / `unittest.mock.patch("src.tools.urllib.request.urlopen")` for web tools. +- Network patches target the submodule where `urllib` / `socket` is imported, not the package: `patch("src.tools.slack.urllib.request.urlopen")` for Slack file fetch, `patch("src.tools.search.urllib.request.urlopen")` for `search_web`, `patch("src.tools.web.urllib.request.urlopen")` and `monkeypatch.setattr("src.tools.web.socket.getaddrinfo", …)` for `fetch_webpage`. - `ScriptedLLM` (in `tests/test_agent.py`) emits predefined `LLMResult` sequences to drive the agent loop without any network. - Provider tests use `MagicMock` clients — no real OpenAI / Bedrock / xAI calls. - `tests/test_config.py` builds `Settings` from `monkeypatch`-controlled env without reloading the module. - `reportlab` (dev-only) synthesizes real PDFs for `read_attached_document` parser coverage. -Per-module coverage: `agent.py` 96%, `config.py` 98%, `tools.py` 90%, `llm.py` 84%, `slack_helpers.py` 83%, `dedup.py` 78%, `logging_utils.py` 68%. +Per-module coverage: + +- `agent.py` 96%, `config.py` 98%, `dedup.py` 78%, `slack_helpers.py` 83%, `logging_utils.py` 68% +- `llms/`: `base.py` 70%, `openai_wire.py` 96%, `openai.py` 100%, `xai.py` 100%, `bedrock.py` 76%, `composite.py` 87%, `factory.py` 94% +- `tools/`: `registry.py` 100%, `slack.py` 86%, `search.py` 93%, `web.py` 97%, `image.py` 100%, `time.py` 100% ## Things that are easy to break diff --git a/docs/extending.md b/docs/extending.md index d8b6745..b103a1c 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -132,31 +132,29 @@ Provider 는 `LLMProvider` Protocol 을 구현하는 클래스입니다. 텍스 """MistralProvider — OpenAI-wire compatible via https://api.mistral.ai/v1.""" from __future__ import annotations -from typing import Any - from src.llms.openai_wire import _OpenAICompatProvider class MistralProvider(_OpenAICompatProvider): """Mistral 의 chat completions API 는 OpenAI wire 와 호환되므로 - `_OpenAICompatProvider` 를 재사용합니다 (xAI 와 같은 패턴).""" - - def __init__(self, model: str, api_key: str, max_output_tokens: int): - super().__init__( - model=model, - api_key=api_key, - max_output_tokens=max_output_tokens, - base_url="https://api.mistral.ai/v1", - ) + `_OpenAICompatProvider` 를 재사용합니다 (xAI 와 같은 패턴). - # Mistral 고유의 request 변형이 있으면 override. - # 없으면 base class 로 충분. + `_OpenAICompatProvider.__init__(model, image_model, api_key)` 를 그대로 + 쓰고, `BASE_URL` · `API_KEY_ENV_VAR` 클래스 속성으로 엔드포인트와 키 환경 + 변수 이름만 덮어씁니다. 고유 request 변형이 필요하면 `_token_params` / + `_image_generate_kwargs` 를 오버라이드하세요. + """ + + BASE_URL = "https://api.mistral.ai/v1" + API_KEY_ENV_VAR = "MISTRAL_API_KEY" ``` OpenAI wire 호환이 아닌 provider (예: Bedrock 처럼 고유 SDK) 는 `LLMProvider` Protocol 네 메서드를 직접 구현해야 합니다. `src/llms/bedrock.py` 가 좋은 reference 입니다. ### 2. `factory.py` 에 분기 추가 +`get_llm` 은 provider 문자열에 따라 인스턴스를 만드는 작은 팩토리입니다. API key 는 단일 `api_keys: dict[str, str | None]` 인자로 전달되므로, 새 provider 는 그 dict 에서 자신의 키를 꺼내 씁니다. + `src/llms/factory.py` ```python @@ -168,18 +166,24 @@ def get_llm( model: str, image_provider: str, image_model: str, - max_output_tokens: int, - openai_api_key: str | None = None, - xai_api_key: str | None = None, - mistral_api_key: str | None = None, # ← 추가 + region: str = "us-east-1", + api_keys: dict[str, str | None] | None = None, ) -> LLMProvider: - ... - if provider == "mistral": - if not mistral_api_key: - raise RuntimeError("MISTRAL_API_KEY required for LLM_PROVIDER=mistral") - text_llm = MistralProvider(model, mistral_api_key, max_output_tokens) - elif provider == "openai": - ... + api_keys = api_keys or {} + + def build(p: str) -> LLMProvider: + if p == "bedrock": + return BedrockProvider(model=model, image_model=image_model, region=region) + if p == "xai": + return XAIProvider(model=model, image_model=image_model, api_key=api_keys.get("xai")) + if p == "mistral": # ← 추가 + return MistralProvider(model=model, image_model=image_model, api_key=api_keys.get("mistral")) + return OpenAIProvider(model=model, image_model=image_model) + + text = build(provider) + if image_provider == provider: + return text + return _CompositeProvider(text=text, image=build(image_provider)) ``` ### 3. Settings 에 API key 필드 @@ -212,7 +216,34 @@ _VALID_PROVIDERS = {"openai", "bedrock", "xai", "mistral"} ### 5. `get_llm` 호출부 업데이트 -`app.py` 와 `localtest.py` 가 `Settings` 를 `get_llm` 으로 풀어서 전달하는 부분에 `mistral_api_key=settings.mistral_api_key` 를 추가합니다. +`app.py` 와 `localtest.py` 는 `api_keys` dict 에 provider-key 쌍을 담아 `get_llm` 에 넘깁니다. 새 provider 를 위해 dict 항목을 추가합니다. + +```python +# app.py / localtest.py — 기존 +llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={"xai": settings.xai_api_key}, +) + +# ↓ Mistral 지원 추가 후 +llm = get_llm( + provider=settings.llm_provider, + model=settings.llm_model, + image_provider=settings.image_provider, + image_model=settings.image_model, + region=settings.aws_region, + api_keys={ + "xai": settings.xai_api_key, + "mistral": settings.mistral_api_key, # ← 추가 + }, +) +``` + +`OPENAI_API_KEY` 는 OpenAI Python SDK 가 환경 변수에서 직접 읽으므로 `api_keys` dict 에 넣지 않습니다 — 환경 변수만 설정하면 됩니다. Bedrock 도 AWS SDK 의 credential chain 을 사용하므로 별도 key 전달이 불필요합니다. ### 6. 테스트 작성 @@ -222,19 +253,22 @@ _VALID_PROVIDERS = {"openai", "bedrock", "xai", "mistral"} """Tests for src.llms.mistral.""" from __future__ import annotations -import json -from unittest.mock import MagicMock, patch - from src.llms.mistral import MistralProvider -def test_mistral_chat_uses_mistral_base_url(): +def test_mistral_provider_exposes_mistral_base_url(): + assert MistralProvider.BASE_URL == "https://api.mistral.ai/v1" + assert MistralProvider.API_KEY_ENV_VAR == "MISTRAL_API_KEY" + + +def test_mistral_provider_constructs_openai_client_with_base_url(): provider = MistralProvider( model="mistral-large", + image_model="", api_key="test-key", - max_output_tokens=256, ) - assert provider.client.base_url.startswith("https://api.mistral.ai/") + client = provider._get_client() + assert str(client.base_url).startswith("https://api.mistral.ai/") ``` OpenAI-wire 호환 provider 는 `_OpenAICompatProvider` 테스트 패턴을 참고 (`tests/llms/test_xai.py` 가 좋은 예시). From 5c3a6007487e91c5b6c7e7292502387667662caa Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:52:07 +0900 Subject: [PATCH 40/65] chore(deploy): wire MAX_WEB_*/JINA_READER_BASE and MAX_DOC_*/DEFAULT_TIMEZONE through CI+serverless --- .github/workflows/push.yml | 8 ++++++++ README.md | 2 +- serverless.yml | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 92b5dfe..ffa3815 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -69,4 +69,12 @@ jobs: ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} LOG_LEVEL: ${{ vars.LOG_LEVEL }} + DEFAULT_TIMEZONE: ${{ vars.DEFAULT_TIMEZONE }} + MAX_DOC_CHARS: ${{ vars.MAX_DOC_CHARS }} + MAX_DOC_PAGES: ${{ vars.MAX_DOC_PAGES }} + MAX_DOC_BYTES: ${{ vars.MAX_DOC_BYTES }} + MAX_WEB_CHARS: ${{ vars.MAX_WEB_CHARS }} + MAX_WEB_BYTES: ${{ vars.MAX_WEB_BYTES }} + MAX_WEB_LINKS: ${{ vars.MAX_WEB_LINKS }} + JINA_READER_BASE: ${{ vars.JINA_READER_BASE }} run: npx serverless deploy --stage dev --region us-east-1 diff --git a/README.md b/README.md index 2125025..dc60a67 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${AC ### 2. GitHub 저장소 설정 - **Secrets**: `AWS_ACCOUNT_ID`, `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `OPENAI_API_KEY`, `XAI_API_KEY`(xAI 사용 시), `TAVILY_API_KEY`(선택) -- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `SYSTEM_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES` +- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `SYSTEM_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES`, `MAX_WEB_CHARS`, `MAX_WEB_BYTES`, `MAX_WEB_LINKS`, `JINA_READER_BASE` ### 3. 배포 diff --git a/serverless.yml b/serverless.yml index ca90051..a0ec4e3 100644 --- a/serverless.yml +++ b/serverless.yml @@ -35,6 +35,10 @@ provider: MAX_DOC_CHARS: ${env:MAX_DOC_CHARS, '20000'} MAX_DOC_PAGES: ${env:MAX_DOC_PAGES, '50'} MAX_DOC_BYTES: ${env:MAX_DOC_BYTES, '26214400'} + MAX_WEB_CHARS: ${env:MAX_WEB_CHARS, '8000'} + MAX_WEB_BYTES: ${env:MAX_WEB_BYTES, '2097152'} + MAX_WEB_LINKS: ${env:MAX_WEB_LINKS, '20'} + JINA_READER_BASE: ${env:JINA_READER_BASE, 'https://r.jina.ai'} SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''} From d36faa7037da170bc8b6b0ef1603724c2eceb60d Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 17:53:55 +0900 Subject: [PATCH 41/65] =?UTF-8?q?chore(deploy):=20align=20env=20order=20ac?= =?UTF-8?q?ross=20push.yml=20and=20serverless.yml=20(secrets=E2=86=92vars,?= =?UTF-8?q?=20grouped,=20A-Z)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/push.yml | 33 ++++++++++++++++++--------- serverless.yml | 46 ++++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ffa3815..93c71fa 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -49,32 +49,43 @@ jobs: - name: Deploy to AWS Lambda 🚀 env: + # --- secrets: Slack --- SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} + # --- secrets: LLM provider keys --- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + # --- secrets: Web search --- TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} - LLM_PROVIDER: ${{ vars.LLM_PROVIDER }} - LLM_MODEL: ${{ vars.LLM_MODEL }} - IMAGE_PROVIDER: ${{ vars.IMAGE_PROVIDER }} + # --- vars: LLM / Image --- IMAGE_MODEL: ${{ vars.IMAGE_MODEL }} - RESPONSE_LANGUAGE: ${{ vars.RESPONSE_LANGUAGE }} + IMAGE_PROVIDER: ${{ vars.IMAGE_PROVIDER }} + LLM_MODEL: ${{ vars.LLM_MODEL }} + LLM_PROVIDER: ${{ vars.LLM_PROVIDER }} + # --- vars: Agent behavior --- AGENT_MAX_STEPS: ${{ vars.AGENT_MAX_STEPS }} MAX_OUTPUT_TOKENS: ${{ vars.MAX_OUTPUT_TOKENS }} - MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} - MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} - MAX_HISTORY_CHARS: ${{ vars.MAX_HISTORY_CHARS }} + RESPONSE_LANGUAGE: ${{ vars.RESPONSE_LANGUAGE }} + # --- vars: Slack UX --- BOT_CURSOR: ${{ vars.BOT_CURSOR }} + MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} + SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} + # --- vars: Access control / throttle --- ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} - SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} + MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} + # --- vars: Storage --- + MAX_HISTORY_CHARS: ${{ vars.MAX_HISTORY_CHARS }} + # --- vars: Observability --- LOG_LEVEL: ${{ vars.LOG_LEVEL }} + # --- vars: Document / time --- DEFAULT_TIMEZONE: ${{ vars.DEFAULT_TIMEZONE }} + MAX_DOC_BYTES: ${{ vars.MAX_DOC_BYTES }} MAX_DOC_CHARS: ${{ vars.MAX_DOC_CHARS }} MAX_DOC_PAGES: ${{ vars.MAX_DOC_PAGES }} - MAX_DOC_BYTES: ${{ vars.MAX_DOC_BYTES }} - MAX_WEB_CHARS: ${{ vars.MAX_WEB_CHARS }} + # --- vars: Web fetch --- + JINA_READER_BASE: ${{ vars.JINA_READER_BASE }} MAX_WEB_BYTES: ${{ vars.MAX_WEB_BYTES }} + MAX_WEB_CHARS: ${{ vars.MAX_WEB_CHARS }} MAX_WEB_LINKS: ${{ vars.MAX_WEB_LINKS }} - JINA_READER_BASE: ${{ vars.JINA_READER_BASE }} run: npx serverless deploy --stage dev --region us-east-1 diff --git a/serverless.yml b/serverless.yml index a0ec4e3..0c23f9e 100644 --- a/serverless.yml +++ b/serverless.yml @@ -14,36 +14,48 @@ provider: memorySize: 5120 timeout: 90 environment: - DYNAMODB_TABLE_NAME: ${self:service}-${sls:stage} + # --- runtime-internal (serverless-provided, not from CI env) --- AWS_REGION_NAME: ${self:provider.region} - LLM_PROVIDER: ${env:LLM_PROVIDER, 'openai'} - LLM_MODEL: ${env:LLM_MODEL, 'gpt-4o-mini'} - IMAGE_PROVIDER: ${env:IMAGE_PROVIDER, 'openai'} + DYNAMODB_TABLE_NAME: ${self:service}-${sls:stage} + # --- secrets: Slack --- + SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} + # --- secrets: LLM provider keys --- + OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''} + XAI_API_KEY: ${env:XAI_API_KEY, ''} + # --- secrets: Web search --- + TAVILY_API_KEY: ${env:TAVILY_API_KEY, ''} + # --- vars: LLM / Image --- IMAGE_MODEL: ${env:IMAGE_MODEL, 'gpt-image-1'} - RESPONSE_LANGUAGE: ${env:RESPONSE_LANGUAGE, 'ko'} + IMAGE_PROVIDER: ${env:IMAGE_PROVIDER, 'openai'} + LLM_MODEL: ${env:LLM_MODEL, 'gpt-4o-mini'} + LLM_PROVIDER: ${env:LLM_PROVIDER, 'openai'} + # --- vars: Agent behavior --- AGENT_MAX_STEPS: ${env:AGENT_MAX_STEPS, '3'} MAX_OUTPUT_TOKENS: ${env:MAX_OUTPUT_TOKENS, '4096'} - MAX_LEN_SLACK: ${env:MAX_LEN_SLACK, '3000'} - MAX_THROTTLE_COUNT: ${env:MAX_THROTTLE_COUNT, '100'} - MAX_HISTORY_CHARS: ${env:MAX_HISTORY_CHARS, '4000'} + RESPONSE_LANGUAGE: ${env:RESPONSE_LANGUAGE, 'ko'} + # --- vars: Slack UX --- BOT_CURSOR: ${env:BOT_CURSOR, ':robot_face:'} + MAX_LEN_SLACK: ${env:MAX_LEN_SLACK, '3000'} + SYSTEM_MESSAGE: ${env:SYSTEM_MESSAGE, ''} + # --- vars: Access control / throttle --- ALLOWED_CHANNEL_IDS: ${env:ALLOWED_CHANNEL_IDS, ''} ALLOWED_CHANNEL_MESSAGE: ${env:ALLOWED_CHANNEL_MESSAGE, ''} - SYSTEM_MESSAGE: ${env:SYSTEM_MESSAGE, ''} + MAX_THROTTLE_COUNT: ${env:MAX_THROTTLE_COUNT, '100'} + # --- vars: Storage --- + MAX_HISTORY_CHARS: ${env:MAX_HISTORY_CHARS, '4000'} + # --- vars: Observability --- LOG_LEVEL: ${env:LOG_LEVEL, 'INFO'} + # --- vars: Document / time --- DEFAULT_TIMEZONE: ${env:DEFAULT_TIMEZONE, 'Asia/Seoul'} + MAX_DOC_BYTES: ${env:MAX_DOC_BYTES, '26214400'} MAX_DOC_CHARS: ${env:MAX_DOC_CHARS, '20000'} MAX_DOC_PAGES: ${env:MAX_DOC_PAGES, '50'} - MAX_DOC_BYTES: ${env:MAX_DOC_BYTES, '26214400'} - MAX_WEB_CHARS: ${env:MAX_WEB_CHARS, '8000'} + # --- vars: Web fetch --- + JINA_READER_BASE: ${env:JINA_READER_BASE, 'https://r.jina.ai'} MAX_WEB_BYTES: ${env:MAX_WEB_BYTES, '2097152'} + MAX_WEB_CHARS: ${env:MAX_WEB_CHARS, '8000'} MAX_WEB_LINKS: ${env:MAX_WEB_LINKS, '20'} - JINA_READER_BASE: ${env:JINA_READER_BASE, 'https://r.jina.ai'} - SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} - SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} - OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''} - XAI_API_KEY: ${env:XAI_API_KEY, ''} - TAVILY_API_KEY: ${env:TAVILY_API_KEY, ''} iam: role: statements: From cda6b5f67bd1dff270d2ee14b15aec45368563d5 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 18:00:41 +0900 Subject: [PATCH 42/65] chore: add stackTags and tags for katalog_id in serverless.yml --- serverless.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverless.yml b/serverless.yml index 0c23f9e..ee7272c 100644 --- a/serverless.yml +++ b/serverless.yml @@ -13,6 +13,10 @@ provider: stage: ${opt:stage, 'dev'} memorySize: 5120 timeout: 90 + stackTags: + katalog_id: "858" + tags: + katalog_id: "858" environment: # --- runtime-internal (serverless-provided, not from CI env) --- AWS_REGION_NAME: ${self:provider.region} From f0ce062e88b0efdd973a6c41228f4c60c504e1b4 Mon Sep 17 00:00:00 2001 From: nalbam Date: Mon, 20 Apr 2026 19:33:10 +0900 Subject: [PATCH 43/65] feat: enhance UserNameCache with thread-safe warm method and parallel fetching for user names --- src/slack_helpers.py | 33 +++++- src/tools/slack.py | 190 +++++++++++++++++++------------ tests/test_slack_helpers.py | 49 ++++++++ tests/tools/test_slack.py | 220 ++++++++++++++++++++++++++++++++++++ 4 files changed, 414 insertions(+), 78 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index df92307..33fd797 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -3,8 +3,10 @@ import logging import re +import threading import time -from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field from typing import Any, Iterable from slack_sdk.errors import SlackApiError @@ -314,9 +316,15 @@ def stop(self, final_text: str) -> None: @dataclass class UserNameCache: - """Module-level cache keyed by user_id. Survives warm starts.""" + """Module-level cache keyed by user_id. Survives warm starts. + + Thread-safe: `warm()` resolves cache misses in parallel threads, so + cache writes go through `_lock`. Reads are lock-free (a `dict.get` + on an existing key is GIL-atomic in CPython; a worst-case race just + causes a redundant `users_info` call, never a corrupt cache).""" _cache: dict[str, str] + _lock: threading.Lock = field(default_factory=threading.Lock) @classmethod def _default(cls) -> "UserNameCache": @@ -325,8 +333,9 @@ def _default(cls) -> "UserNameCache": def get(self, client: Any, user_id: str) -> str: if not user_id: return "" - if user_id in self._cache: - return self._cache[user_id] + cached = self._cache.get(user_id) + if cached is not None: + return cached try: info = client.users_info(user=user_id) profile = (info.get("user") or {}).get("profile") or {} @@ -339,9 +348,23 @@ def get(self, client: Any, user_id: str) -> str: except SlackApiError as exc: logger.debug("users_info failed for %s: %s", user_id, exc) name = user_id - self._cache[user_id] = name + with self._lock: + self._cache[user_id] = name return name + def warm(self, client: Any, user_ids: Iterable[str]) -> None: + """Pre-resolve display names for the given user IDs in parallel. + + Used by callers that know they'll need many user names before the + rendering loop starts (e.g. `fetch_thread_history`). Without this, + `get()` runs serially inside the loop and 50 cache misses become + 50 sequential `users_info` calls, easily blowing the tool timeout.""" + misses = list({uid for uid in user_ids if uid and uid not in self._cache}) + if not misses: + return + with ThreadPoolExecutor(max_workers=min(len(misses), 8)) as pool: + list(pool.map(lambda uid: self.get(client, uid), misses)) + user_name_cache = UserNameCache._default() diff --git a/src/tools/slack.py b/src/tools/slack.py index 480e512..30745cd 100644 --- a/src/tools/slack.py +++ b/src/tools/slack.py @@ -7,6 +7,7 @@ import urllib.error import urllib.parse import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Callable from slack_sdk.errors import SlackApiError @@ -42,6 +43,9 @@ }, "required": [], }, + # Each image runs Slack download (15s) + LLM describe (multi-second) in + # parallel; the 60s ceiling is a generous safety net for limit=10. + timeout=60.0, ) def read_attached_images( ctx: ToolContext, @@ -49,43 +53,58 @@ def read_attached_images( urls: list[str] | None = None, ) -> list[dict[str, str]]: token = ctx.settings.slack_bot_token - out: list[dict[str, str]] = [] seen: set[str] = set() - - def _fetch(url: str, mime_hint: str, name: str) -> None: - if url in seen: - return - seen.add(url) - parsed = urllib.parse.urlparse(url) - if parsed.scheme != "https" or parsed.hostname not in SLACK_FILE_HOSTS: - raise ValueError("invalid Slack file download URL") - req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) - with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 (host allowlisted) - data = response.read() - mime = mime_hint if mime_hint.startswith("image/") else _guess_image_mime(url) - if not mime.startswith("image/"): - return - out.append({"name": name, "summary": ctx.llm.describe_image(data, mime)}) + candidates: list[tuple[str, str, str]] = [] # (url, mime_hint, name) # 1) Images from the current mention event for file_info in (ctx.event.get("files") or [])[:limit]: - if len(out) >= limit: + if len(candidates) >= limit: break mime = str(file_info.get("mimetype", "")) if not mime.startswith("image/"): continue dl = file_info.get("url_private_download") or file_info.get("url_private") - if not dl: + if not dl or dl in seen: continue - _fetch(dl, mime, file_info.get("name", "image")) + seen.add(dl) + candidates.append((dl, mime, file_info.get("name", "image"))) # 2) Extra URLs provided by the caller (typically from fetch_thread_history) for extra in (urls or []): - if len(out) >= limit: + if len(candidates) >= limit: break - _fetch(extra, "", _filename_from_url(extra)) + if extra in seen: + continue + seen.add(extra) + candidates.append((extra, "", _filename_from_url(extra))) - return out + # Pre-flight SSRF check: validate every URL we plan to fetch BEFORE we + # spin up worker threads, so an invalid host raises synchronously like + # the previous serial implementation did. + for url, _, _ in candidates: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in SLACK_FILE_HOSTS: + raise ValueError("invalid Slack file download URL") + + if not candidates: + return [] + + def _fetch(url: str, mime_hint: str, name: str) -> dict[str, str] | None: + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=15) as response: # noqa: S310 (host allowlisted) + data = response.read() + mime = mime_hint if mime_hint.startswith("image/") else _guess_image_mime(url) + if not mime.startswith("image/"): + return None + return {"name": name, "summary": ctx.llm.describe_image(data, mime)} + + results: list[dict[str, str] | None] = [None] * len(candidates) + with ThreadPoolExecutor(max_workers=min(len(candidates), 4)) as pool: + future_to_idx = {pool.submit(_fetch, *c): i for i, c in enumerate(candidates)} + for future in as_completed(future_to_idx): + results[future_to_idx[future]] = future.result() + + return [r for r in results if r is not None] def _guess_image_mime(url: str) -> str: @@ -205,7 +224,10 @@ def _parse_text(data: bytes, max_chars: int) -> tuple[str, bool]: }, "required": [], }, - timeout=30.0, + # Each document downloads from Slack (15s) and is parsed locally; + # parallelized below, but the 60s ceiling covers the worst case where + # max=5 documents all hit the download timeout. + timeout=60.0, ) def read_attached_document( ctx: ToolContext, @@ -215,25 +237,43 @@ def read_attached_document( token = ctx.settings.slack_bot_token max_bytes = ctx.settings.max_doc_bytes max_chars = ctx.settings.max_doc_chars - out: list[dict[str, Any]] = [] seen: set[str] = set() + candidates: list[tuple[str, str, str]] = [] # (url, mime_hint, name) def _is_doc_mime(mime: str) -> bool: mime = (mime or "").lower() return mime == DOC_PDF_MIME or mime.startswith(DOC_TEXT_PREFIX) - def _process(url: str, file_mime_hint: str, name: str) -> None: - if url in seen or len(out) >= limit: - return - seen.add(url) + for file_info in (ctx.event.get("files") or [])[:limit]: + if len(candidates) >= limit: + break + mime = str(file_info.get("mimetype", "")) + if not _is_doc_mime(mime): + continue + dl = file_info.get("url_private_download") or file_info.get("url_private") + if not dl or dl in seen: + continue + seen.add(dl) + candidates.append((dl, mime, file_info.get("name", "document"))) + + for extra in (urls or []): + if len(candidates) >= limit: + break + if extra in seen: + continue + seen.add(extra) + candidates.append((extra, "", _filename_from_url(extra))) + + if not candidates: + return [] + + def _process_one(url: str, file_mime_hint: str, name: str) -> dict[str, Any] | None: try: body, header_mime = _fetch_slack_file(url, token, max_bytes) except ValueError as exc: - out.append({"name": name, "error": str(exc)}) - return + return {"name": name, "error": str(exc)} except urllib.error.HTTPError as exc: - out.append({"name": name, "error": f"HTTPError: {exc.code}"}) - return + return {"name": name, "error": f"HTTPError: {exc.code}"} mime = (header_mime or file_mime_hint or "").lower() if mime == DOC_PDF_MIME: try: @@ -241,51 +281,35 @@ def _process(url: str, file_mime_hint: str, name: str) -> None: body, ctx.settings.max_doc_pages, max_chars ) except ValueError as exc: - out.append({"name": name, "error": str(exc)}) - return - out.append( - { - "name": name, - "mimetype": DOC_PDF_MIME, - "pages": pages, - "chars": len(text), - "truncated": truncated, - "text": text, - } - ) - return + return {"name": name, "error": str(exc)} + return { + "name": name, + "mimetype": DOC_PDF_MIME, + "pages": pages, + "chars": len(text), + "truncated": truncated, + "text": text, + } if mime.startswith(DOC_TEXT_PREFIX): text, truncated = _parse_text(body, max_chars) - out.append( - { - "name": name, - "mimetype": mime, - "pages": 0, - "chars": len(text), - "truncated": truncated, - "text": text, - } - ) - return + return { + "name": name, + "mimetype": mime, + "pages": 0, + "chars": len(text), + "truncated": truncated, + "text": text, + } # non-doc mime: silently skip (images handled by read_attached_images) + return None - for file_info in (ctx.event.get("files") or [])[:limit]: - if len(out) >= limit: - break - mime = str(file_info.get("mimetype", "")) - if not _is_doc_mime(mime): - continue - dl = file_info.get("url_private_download") or file_info.get("url_private") - if not dl: - continue - _process(dl, mime, file_info.get("name", "document")) - - for extra in (urls or []): - if len(out) >= limit: - break - _process(extra, "", _filename_from_url(extra)) + results: list[dict[str, Any] | None] = [None] * len(candidates) + with ThreadPoolExecutor(max_workers=min(len(candidates), 4)) as pool: + future_to_idx = {pool.submit(_process_one, *c): i for i, c in enumerate(candidates)} + for future in as_completed(future_to_idx): + results[future_to_idx[future]] = future.result() - return out + return [r for r in results if r is not None] @tool( @@ -303,12 +327,32 @@ def _process(url: str, file_mime_hint: str, name: str) -> None: "properties": {"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}}, "required": [], }, + # conversations_replies + up to ~limit users_info lookups (parallelized + # via UserNameCache.warm). 30s leaves headroom for retry backoff. + timeout=30.0, ) def fetch_thread_history(ctx: ToolContext, limit: int = 20) -> list[dict[str, Any]]: def _map(res: dict[str, Any]) -> list[dict[str, Any]]: client = ctx.slack_client + messages = res.get("messages", []) + + # Resolve every author/reacter we'll need in parallel before the + # rendering loop. With a cold cache and limit=50 this would + # otherwise be 50+ serial users_info calls (the original timeout + # bug for read_attached_images, repeating itself here). + user_ids: set[str] = set() + for item in messages: + uid = item.get("user") or item.get("bot_id") + if uid: + user_ids.add(uid) + for r in item.get("reactions") or []: + for u in (r.get("users") or []): + if u: + user_ids.add(u) + user_name_cache.warm(client, user_ids) + out: list[dict[str, Any]] = [] - for item in res.get("messages", []): + for item in messages: user_id = item.get("user") or item.get("bot_id") or "" files = [] for f in item.get("files") or []: diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index 7b93d92..d21c011 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -73,6 +73,55 @@ def test_user_name_cache_falls_back_to_user_id_on_error(): assert cache.get(client, "U2") == "U2" +def test_user_name_cache_warm_resolves_misses_in_parallel(): + """warm() must hit users_info once per uncached id and run them + concurrently — the original serial pattern blew the tool timeout + when many new users appeared in a thread.""" + import threading + import time as _time + + cache = UserNameCache._default() + client = MagicMock() + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_users_info(user): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.2) + with lock: + in_flight -= 1 + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client.users_info.side_effect = _slow_users_info + + started = _time.monotonic() + cache.warm(client, ["U1", "U2", "U3", "U4"]) + elapsed = _time.monotonic() - started + + assert peak >= 2, f"expected concurrent users_info, peak={peak}" + assert elapsed < 0.6, f"expected parallel ~0.2s, took {elapsed:.2f}s" + assert cache.get(client, "U1") == "name-U1" + # warm populated the cache, so get() does not trigger a second API call + assert client.users_info.call_count == 4 + + +def test_user_name_cache_warm_skips_cached_and_empty_ids(): + cache = UserNameCache._default() + client = MagicMock() + client.users_info.return_value = {"user": {"profile": {"display_name": "Alice"}}} + cache.get(client, "U1") # prime cache + client.users_info.reset_mock() + + cache.warm(client, ["U1", "", None, "U1"]) # all already-cached or skippable + + client.users_info.assert_not_called() + + def test_channel_allowed_no_allowlist(): assert channel_allowed("C1", []) is True diff --git a/tests/tools/test_slack.py b/tests/tools/test_slack.py index 9a4c731..68069db 100644 --- a/tests/tools/test_slack.py +++ b/tests/tools/test_slack.py @@ -121,6 +121,51 @@ def _users_info(user): ] +def test_fetch_thread_history_resolves_users_concurrently(): + """fetch_thread_history must prefetch user names in parallel via + UserNameCache.warm. Without this, a thread of N unique users + R + reacters becomes N+R serial users_info calls and blows the tool + timeout on cold caches.""" + import threading + import time as _time + + from src.slack_helpers import user_name_cache + + user_name_cache._cache.clear() + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_users_info(user): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.2) + with lock: + in_flight -= 1 + return {"user": {"profile": {"display_name": f"name-{user}"}}} + + client = MagicMock() + client.users_info.side_effect = _slow_users_info + client.conversations_replies.return_value = { + "messages": [ + {"user": f"U{i}", "text": "hi", "ts": f"100.{i}"} + for i in range(4) + ] + } + + started = _time.monotonic() + out = fetch_thread_history(_ctx(slack_client=client), limit=10) + elapsed = _time.monotonic() - started + + assert len(out) == 4 + assert peak >= 2, f"expected concurrent users_info, peak={peak}" + assert elapsed < 0.6, f"expected parallel ~0.2s, took {elapsed:.2f}s" + assert {item["user"] for item in out} == {f"name-U{i}" for i in range(4)} + + def test_read_attached_images_accepts_extra_urls(): """Images referenced from fetch_thread_history (url_private_download) must be loadable via read_attached_images(urls=[...]).""" @@ -142,6 +187,100 @@ def test_read_attached_images_urls_reject_non_slack_host(): read_attached_images(ctx, urls=["https://evil.example.com/cat.png"]) +def test_read_attached_images_runs_describes_in_parallel(): + """The describe step is the slow one (LLM call). Three images that each + take 0.3s to describe should finish in well under the serial 0.9s if the + pool is actually parallel. Guards against accidentally re-serializing the + fetch loop (the original bug).""" + import threading + import time as _time + + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": f"https://files.slack.com/img{i}.png", + "name": f"img{i}.png", + } + for i in range(3) + ] + } + ctx = _ctx(event=event) + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_describe(_data, _mime): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.3) + with lock: + in_flight -= 1 + return "described" + + ctx.llm.describe_image.side_effect = _slow_describe + + with patch("src.tools.slack.urllib.request.urlopen") as opener: + opener.return_value.__enter__.return_value.read.return_value = b"x" + started = _time.monotonic() + out = read_attached_images(ctx, limit=3) + elapsed = _time.monotonic() - started + + assert len(out) == 3 + assert peak >= 2, f"expected concurrent describes, peak={peak}" + assert elapsed < 0.7, f"expected parallel ~0.3s, took {elapsed:.2f}s" + + +def test_read_attached_images_preserves_order(): + """Output order must match candidate order (event files first, then urls), + independent of which describe call finishes first.""" + import threading + import time as _time + + event = { + "files": [ + { + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/event.png", + "name": "event.png", + } + ] + } + ctx = _ctx(event=event) + + delays = {b"event": 0.2, b"extra": 0.0} + barrier = threading.Event() + + def _describe(data, _mime): + # Force the extra image to finish first so we can verify ordering + # comes from the candidate index, not completion order. + _time.sleep(delays.get(data, 0.0)) + if data == b"extra": + barrier.set() + return f"sum-{data.decode()}" + + ctx.llm.describe_image.side_effect = _describe + + def _open(req, timeout=15): # noqa: ARG001 + url = req.full_url if hasattr(req, "full_url") else req + body = b"event" if "event.png" in url else b"extra" + cm = MagicMock() + cm.__enter__.return_value.read.return_value = body + return cm + + with patch("src.tools.slack.urllib.request.urlopen", side_effect=_open): + out = read_attached_images( + ctx, + limit=3, + urls=["https://files.slack.com/extra.png"], + ) + + assert [item["name"] for item in out] == ["event.png", "extra.png"] + + def test_read_attached_images_respects_total_limit_across_event_and_urls(): event = { "files": [ @@ -173,6 +312,87 @@ def test_read_attached_images_respects_total_limit_across_event_and_urls(): # --------------------------------------------------------------------------- # +def test_read_attached_document_runs_in_parallel(): + """Multiple text documents should download concurrently. The serial + implementation took ~3 * download_time; parallel should finish in + roughly download_time. Guards against re-serializing the loop.""" + import threading + import time as _time + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": f"https://files.slack.com/d{i}.txt", + "name": f"d{i}.txt", + } + for i in range(3) + ] + } + ctx = _ctx(event=event) + + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def _slow_open(_req, timeout=15): # noqa: ARG001 + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + _time.sleep(0.3) + with lock: + in_flight -= 1 + cm = MagicMock() + cm.__enter__.return_value.read.return_value = b"hello" + cm.__enter__.return_value.headers = {"Content-Length": "5", "Content-Type": "text/plain"} + return cm + + with patch("src.tools.slack.urllib.request.urlopen", side_effect=_slow_open): + started = _time.monotonic() + out = read_attached_document(ctx, limit=3) + elapsed = _time.monotonic() - started + + assert len(out) == 3 + assert peak >= 2, f"expected concurrent fetches, peak={peak}" + assert elapsed < 0.7, f"expected parallel ~0.3s, took {elapsed:.2f}s" + + +def test_read_attached_document_preserves_order_under_parallel_completion(): + """Output order must follow candidate order, not completion order.""" + import time as _time + + event = { + "files": [ + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/slow.txt", + "name": "slow.txt", + }, + { + "mimetype": "text/plain", + "url_private_download": "https://files.slack.com/fast.txt", + "name": "fast.txt", + }, + ] + } + ctx = _ctx(event=event) + + def _open(req, timeout=15): # noqa: ARG001 + url = req.full_url if hasattr(req, "full_url") else req + delay = 0.2 if "slow.txt" in url else 0.0 + _time.sleep(delay) + cm = MagicMock() + cm.__enter__.return_value.read.return_value = b"x" + cm.__enter__.return_value.headers = {"Content-Length": "1", "Content-Type": "text/plain"} + return cm + + with patch("src.tools.slack.urllib.request.urlopen", side_effect=_open): + out = read_attached_document(ctx, limit=2) + + assert [item["name"] for item in out] == ["slow.txt", "fast.txt"] + + def test_read_attached_document_text_file(): event = { "files": [ From 194a89459d3b43d2c2d46cd6486b12401c6c8818 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:26:35 +0900 Subject: [PATCH 44/65] fix: route Bedrock Nova vision through Converse API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BedrockProvider.describe_image always built an Anthropic Messages body, so vision requests failed with ValidationException when the configured LLM_MODEL was a Nova ID (amazon.nova-pro-v1:0 and its us./eu./apac./ global. inference-profile variants). chat() already family-routes between Claude and Nova, but the vision entrypoint did not — read_attached_images blew up for every Nova-based deployment. Split describe_image by _text_family: Claude keeps the Messages body path, Nova goes through converse() with an {image: {format, source: {bytes}}} content block. MIME is mapped to Nova's short format (image/jpeg → jpeg) with a conservative png fallback for unknown types. Also hardens _to_nova_messages tool content — dict/list payloads now go through json.dumps instead of str() repr, so callers that hand us structured content don't end up with un-parseable Python syntax in Nova's text blocks. --- src/llms/bedrock.py | 61 +++++++++++++++++++++++-- tests/llms/test_bedrock.py | 94 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/llms/bedrock.py b/src/llms/bedrock.py index 864e5ca..2af5de8 100644 --- a/src/llms/bedrock.py +++ b/src/llms/bedrock.py @@ -244,7 +244,25 @@ def stream_chat( # -- vision / image ----------------------------------------------------- # + _NOVA_IMAGE_FORMATS = { + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/gif": "gif", + "image/webp": "webp", + } + def describe_image(self, image_bytes: bytes, mime_type: str) -> str: + # Family-route the same way chat() does. Nova models speak the Converse + # API with an `image` content block; sending Claude's Messages body at a + # Nova ID raises ValidationException. Claude (and unknown families, to + # preserve prior behaviour for new Claude-compatible IDs) stays on the + # Messages API. + if self._text_family.startswith("amazon.nova"): + return self._nova_describe_image(image_bytes, mime_type) + return self._claude_describe_image(image_bytes, mime_type) + + def _claude_describe_image(self, image_bytes: bytes, mime_type: str) -> str: encoded = base64.b64encode(image_bytes).decode("utf-8") body = { "anthropic_version": "bedrock-2023-05-31", @@ -267,6 +285,27 @@ def describe_image(self, image_bytes: bytes, mime_type: str) -> str: return block.get("text", "") return "" + def _nova_describe_image(self, image_bytes: bytes, mime_type: str) -> str: + fmt = self._NOVA_IMAGE_FORMATS.get((mime_type or "").lower(), "png") + client = self._get_client() + response = client.converse( + modelId=self.model, + messages=[ + { + "role": "user", + "content": [ + {"text": "Describe this image for a Slack conversation."}, + {"image": {"format": fmt, "source": {"bytes": image_bytes}}}, + ], + } + ], + inferenceConfig={"maxTokens": 512, "temperature": 0.2}, + ) + for block in response.get("output", {}).get("message", {}).get("content", []): + if "text" in block: + return block.get("text", "") + return "" + def generate_image(self, prompt: str) -> bytes: body = self._build_image_body(prompt) client = self._get_client() @@ -332,6 +371,22 @@ def _to_anthropic_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any out.append({"role": role or "user", "content": msg.get("content", "")}) return out + @staticmethod + def _coerce_nova_text(content: Any) -> str: + """Convert arbitrary content into a string suitable for Nova's + `{"text": ...}` content block. + + Strings pass through unchanged. Non-strings (dict, list — e.g. if a + future caller hands us raw tool_result dicts) are JSON-serialized + instead of going through `str(...)`, which would emit Python repr and + leave the LLM unable to parse downstream. + """ + if content is None: + return "" + if isinstance(content, str): + return content + return json.dumps(content, ensure_ascii=False) + @staticmethod def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] @@ -345,7 +400,7 @@ def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: { "toolResult": { "toolUseId": msg.get("tool_call_id", ""), - "content": [{"text": str(msg.get("content", ""))}], + "content": [{"text": BedrockProvider._coerce_nova_text(msg.get("content"))}], } } ], @@ -354,10 +409,10 @@ def _to_nova_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: elif role == "assistant" and msg.get("tool_calls"): blocks: list[dict[str, Any]] = [] if msg.get("content"): - blocks.append({"text": msg["content"]}) + blocks.append({"text": BedrockProvider._coerce_nova_text(msg["content"])}) for tc in msg["tool_calls"]: blocks.append({"toolUse": {"toolUseId": tc["id"], "name": tc["name"], "input": tc.get("arguments", {})}}) out.append({"role": "assistant", "content": blocks}) else: - out.append({"role": role or "user", "content": [{"text": str(msg.get("content", ""))}]}) + out.append({"role": role or "user", "content": [{"text": BedrockProvider._coerce_nova_text(msg.get("content"))}]}) return out diff --git a/tests/llms/test_bedrock.py b/tests/llms/test_bedrock.py index 62fa011..1f21e22 100644 --- a/tests/llms/test_bedrock.py +++ b/tests/llms/test_bedrock.py @@ -97,6 +97,26 @@ def test_bedrock_claude_chat_with_tool_use(): assert result.tool_calls[0].arguments == {"query": "x"} +def test_bedrock_nova_coerces_dict_tool_content_to_json(): + """If a caller hands us a dict as tool content, Nova's `{"text": ...}` + block must receive JSON, not Python's repr via str().""" + msgs = [ + {"role": "tool", "tool_call_id": "t1", "content": {"ok": True, "count": 2}}, + ] + translated = BedrockProvider._to_nova_messages(msgs) + text = translated[0]["content"][0]["toolResult"]["content"][0]["text"] + assert text == '{"ok": true, "count": 2}' + + +def test_bedrock_nova_preserves_string_tool_content(): + msgs = [ + {"role": "tool", "tool_call_id": "t1", "content": '{"already":"json"}'}, + ] + translated = BedrockProvider._to_nova_messages(msgs) + text = translated[0]["content"][0]["toolResult"]["content"][0]["text"] + assert text == '{"already":"json"}' + + def test_bedrock_message_translation_tool_role(): messages = [ {"role": "user", "content": "ask"}, @@ -194,6 +214,80 @@ def test_bedrock_describe_image_returns_text(): assert out == "a cat" +def test_bedrock_nova_describe_image_uses_converse_not_invoke_model(): + """Nova text models can't accept Claude's Messages body — describe_image + must route to the Converse API with an `image` content block instead.""" + provider = BedrockProvider( + model="amazon.nova-pro-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "a nova cat"}]}}, + } + out = provider.describe_image(b"bytes", "image/png") + assert out == "a nova cat" + provider._client.converse.assert_called_once() + provider._client.invoke_model.assert_not_called() + messages = provider._client.converse.call_args.kwargs["messages"] + img_block = next(b for b in messages[0]["content"] if "image" in b) + assert img_block["image"]["format"] == "png" + assert img_block["image"]["source"] == {"bytes": b"bytes"} + + +def test_bedrock_nova_describe_image_maps_mime_to_nova_format(): + """image/jpeg must be sent as format='jpeg' (Nova only accepts a short + form: png/jpeg/gif/webp).""" + provider = BedrockProvider( + model="amazon.nova-lite-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "x"}]}}, + } + provider.describe_image(b"bytes", "image/jpeg") + messages = provider._client.converse.call_args.kwargs["messages"] + img_block = next(b for b in messages[0]["content"] if "image" in b) + assert img_block["image"]["format"] == "jpeg" + + +def test_bedrock_nova_describe_image_inference_profile_routes_to_converse(): + """`us.amazon.nova-*` inference-profile IDs must still hit the Nova path.""" + provider = BedrockProvider( + model="us.amazon.nova-pro-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "ok"}]}}, + } + provider.describe_image(b"x", "image/png") + provider._client.converse.assert_called_once() + provider._client.invoke_model.assert_not_called() + + +def test_bedrock_nova_describe_image_unknown_mime_falls_back_to_png(): + """Unsupported MIME types must not crash — fall back to 'png' so Nova + receives a valid format value.""" + provider = BedrockProvider( + model="amazon.nova-pro-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + provider._client = MagicMock() + provider._client.converse.return_value = { + "output": {"message": {"content": [{"text": "x"}]}}, + } + provider.describe_image(b"bytes", "application/octet-stream") + messages = provider._client.converse.call_args.kwargs["messages"] + img_block = next(b for b in messages[0]["content"] if "image" in b) + assert img_block["image"]["format"] == "png" + + def test_bedrock_generate_image_titan_returns_bytes(): provider = BedrockProvider( model="anthropic.claude-3-5-sonnet-20240620-v1:0", From 2c9372771e4c56c51f7efa8413876ac9fede1764 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:26:48 +0900 Subject: [PATCH 45/65] fix: close ToolExecutor pool and catch provider exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues in the tool dispatch layer: 1) ToolExecutor created a ThreadPoolExecutor in __init__ and never shut it down. SlackMentionAgent builds a new executor per request, so in Lambda warm containers every invocation added non-daemon workers to the process registry that stayed until interpreter exit. Added ToolExecutor.close() (idempotent, wait=False) and a try/finally in SlackMentionAgent.run() that only closes executors the agent created itself — injected executors remain caller-owned. 2) execute() caught a stdlib allowlist (ValueError, TypeError, URLError, SlackApiError, Boto*). Provider SDKs raise their own hierarchies (openai.APIError, anthropic.APIError, httpx.HTTPError) that don't subclass those, so generate_image failures bubbled past the executor and aborted the whole agent loop with a generic "요청 처리 중 오류" reply. The LLM never got a chance to see the tool_result failure and recover. Broadened to `except Exception` — the agent treats {"ok": False, ...} as recoverable, which is the entire point. --- src/agent.py | 12 +++++++++ src/tools/registry.py | 39 ++++++++++++++++----------- tests/test_agent.py | 51 ++++++++++++++++++++++++++++++++++++ tests/tools/test_registry.py | 43 ++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 15 deletions(-) diff --git a/src/agent.py b/src/agent.py index a830bff..16074b3 100644 --- a/src/agent.py +++ b/src/agent.py @@ -50,6 +50,9 @@ def __init__( self.registry = registry self.max_steps = max_steps self.executor = tool_executor or ToolExecutor(context, registry) + # Only close the executor we created ourselves — an injected one is + # owned by the caller (e.g. a test or a shared long-lived harness). + self._owns_executor = tool_executor is None self.response_language = response_language self.system_message = system_message self.history = history or [] @@ -59,6 +62,15 @@ def __init__( self.max_output_tokens = max_output_tokens def run(self, user_message: str) -> AgentResult: + try: + return self._run(user_message) + finally: + if self._owns_executor: + # Release the ThreadPoolExecutor so Lambda warm containers + # don't accumulate idle workers across requests. + self.executor.close() + + def _run(self, user_message: str) -> AgentResult: system = self._build_system_prompt() messages: list[dict[str, Any]] = [*self.history, {"role": "user", "content": user_message}] seen_calls: set[str] = set() diff --git a/src/tools/registry.py b/src/tools/registry.py index 8f9d516..5f3e763 100644 --- a/src/tools/registry.py +++ b/src/tools/registry.py @@ -3,17 +3,12 @@ via the @tool decorator on import.""" from __future__ import annotations -import json import logging import time -import urllib.error from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from dataclasses import dataclass, field from typing import Any, Callable -from botocore.exceptions import BotoCoreError, ClientError -from slack_sdk.errors import SlackApiError - from src.config import Settings from src.llms import LLMProvider, ToolCall @@ -96,6 +91,7 @@ def __init__(self, context: ToolContext, registry: ToolRegistry, timeout: float self.registry = registry self.timeout = timeout self._pool = ThreadPoolExecutor(max_workers=2) + self._closed = False def execute(self, call: ToolCall) -> dict[str, Any]: td = self.registry.get(call.name) @@ -110,19 +106,32 @@ def execute(self, call: ToolCall) -> dict[str, Any]: except FuturesTimeout: logger.warning("tool %s timed out after %.1fs", call.name, effective_timeout) return {"ok": False, "error": f"tool '{call.name}' timed out after {effective_timeout}s"} - except ( - TypeError, - ValueError, - KeyError, - urllib.error.URLError, - json.JSONDecodeError, - SlackApiError, - BotoCoreError, - ClientError, - ) as exc: + except Exception as exc: # noqa: BLE001 + # Broad catch on purpose: provider SDKs raise their own APIError + # hierarchies (openai.APIError, anthropic.APIError, httpx.HTTPError) + # that were missing from the previous allowlist — and when they + # escaped the executor the whole agent loop aborted with a generic + # error instead of handing the failure back to the LLM to recover. + # The agent already treats {"ok": False, ...} as a recoverable tool + # result, so swallowing here is correct. logger.exception("tool %s failed", call.name) return {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"} + def close(self) -> None: + """Release the worker pool. + + Called by the owning agent at end-of-request. Safe to call twice. + Must be invoked in Lambda warm-start environments — otherwise every + request spawns a fresh ThreadPoolExecutor whose non-daemon workers + stay in the process-wide registry until interpreter exit. + """ + if self._closed: + return + self._closed = True + # wait=False so a timed-out tool's worker doesn't pin the Lambda + # invocation. The stray thread will be cleaned up on GC. + self._pool.shutdown(wait=False) + # --------------------------------------------------------------------------- # # Built-in tools diff --git a/tests/test_agent.py b/tests/test_agent.py index 281586f..a4b8d9a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -236,6 +236,57 @@ def chat(self, system, messages, tools=None, max_tokens=1024, on_delta=None): assert len(llm.calls) == 1 +def test_agent_closes_owned_executor_after_run(): + """SlackMentionAgent must release the ThreadPoolExecutor it created so + Lambda warm containers don't accumulate idle workers across requests.""" + reg = _registry_with_search() + llm = ScriptedLLM([LLMResult(content="done", tool_calls=[], stop_reason="end_turn")]) + agent = SlackMentionAgent(llm=llm, context=_ctx(), registry=reg, max_steps=3) + agent.run("q") + assert agent.executor._closed is True + + +def test_agent_closes_owned_executor_on_exception(): + """Even when the LLM raises, the owned executor must still be closed.""" + reg = _registry_with_search() + + class BoomLLM: + def chat(self, *a, **k): + raise RuntimeError("boom") + + def stream_chat(self, *a, **k): # pragma: no cover + raise RuntimeError("boom") + + def describe_image(self, *a, **k): # pragma: no cover + return "" + + def generate_image(self, *a, **k): # pragma: no cover + return b"" + + agent = SlackMentionAgent(llm=BoomLLM(), context=_ctx(), registry=reg, max_steps=3) + import pytest + + with pytest.raises(RuntimeError): + agent.run("q") + assert agent.executor._closed is True + + +def test_agent_does_not_close_injected_executor(): + """An externally-supplied ToolExecutor is owned by the caller; the agent + must not shut it down.""" + from src.tools.registry import ToolExecutor + + reg = _registry_with_search() + ext_exec = ToolExecutor(_ctx(), reg) + llm = ScriptedLLM([LLMResult(content="done", tool_calls=[], stop_reason="end_turn")]) + agent = SlackMentionAgent( + llm=llm, context=_ctx(), registry=reg, max_steps=3, tool_executor=ext_exec + ) + agent.run("q") + assert ext_exec._closed is False + ext_exec.close() + + def test_agent_aggregates_token_usage(): reg = _registry_with_search() llm = ScriptedLLM( diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index fc28ec6..a3a1a61 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -138,3 +138,46 @@ def test_generate_image_tool_has_extended_timeout(): def test_default_registry_now_includes_fetch_webpage(): names = set(default_registry.names()) assert "fetch_webpage" in names + + +def test_executor_wraps_arbitrary_provider_exception(): + """Tools that call provider SDKs can raise provider-specific errors + (openai.APIError, anthropic.APIError, httpx.HTTPError, etc.) which aren't + subclasses of ValueError/TypeError. The executor must still surface them + as {ok: False, ...} so the LLM can recover — not propagate and abort the + whole agent loop.""" + + class FakeProviderError(Exception): + """Stand-in for openai.APIError / anthropic.APIError etc. — not a + subclass of any stdlib exception category the old allowlist knew + about.""" + + registry = ToolRegistry() + + def provider_call(ctx): + raise FakeProviderError("rate limit: try again in 42s") + + registry.register( + ToolDef( + name="llm_thing", + description="", + parameters={"type": "object", "properties": {}}, + fn=provider_call, + ) + ) + executor = ToolExecutor(_ctx(), registry) + result = executor.execute(ToolCall(id="1", name="llm_thing", arguments={})) + assert result["ok"] is False + assert "FakeProviderError" in result["error"] + assert "rate limit" in result["error"] + + +def test_executor_close_is_idempotent(): + """close() is called by the owning agent at end-of-request — invoking it + twice (e.g. agent.run() running then the caller also calling close) must + not raise.""" + registry = ToolRegistry() + executor = ToolExecutor(_ctx(), registry) + executor.close() + executor.close() + assert executor._closed is True From 373dd7b037a4fa9034535e60f483f4c28329adcf Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:26:58 +0900 Subject: [PATCH 46/65] fix: recover stream on chat_update failures and redact more tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallback streaming path called chat_update and only logged on failure — if the current ts became unusable (deleted, message-level rate limit, permission change) the streamer would burn the rest of the request re-trying the same broken ts until the buffer hit max_len. Added a 3-strike counter that rolls to a fresh chat_postMessage once crossed, with preserve_buffer=True so deltas that never reached Slack are re-sent against the new ts. Successful updates reset the counter. Widened sanitize_error redaction: sk-ant-* (Anthropic), xai-* (Grok), tvly-* (Tavily), and AKIA/ASIA AWS access keys all carried through error messages to end users unredacted. More-specific patterns run before the generic sk-* rule so labels stay accurate. --- src/slack_helpers.py | 47 ++++++++++++++++--- tests/test_slack_helpers.py | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 33fd797..1cf34d0 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -129,6 +129,11 @@ class StreamingMessage: NATIVE_METHOD = "chat.startStream" APPEND_METHOD = "chat.appendStream" STOP_METHOD = "chat.stopStream" + # After this many consecutive chat_update failures on the fallback path, + # finalize the current ts and open a fresh chat_postMessage. Covers the + # case where the current ts is unreachable (deleted, rate-limited on a + # specific msg, etc.) without waiting for the buffer to hit max_len. + MAX_CONSECUTIVE_UPDATE_FAILURES = 3 def __init__( self, @@ -160,6 +165,7 @@ def __init__( self._last_flush = 0.0 self._native = False # True once chat.startStream succeeds self._stopped = False + self._consecutive_update_failures = 0 # -- start ---------------------------------------------------------- # @@ -237,12 +243,34 @@ def _flush(self) -> None: return try: self.client.chat_update(channel=self.channel, ts=self.ts, text=display) + self._consecutive_update_failures = 0 except SlackApiError as exc: - logger.warning("chat_update during stream failed: %s", exc) - - def _roll_to_new_message(self) -> None: + self._consecutive_update_failures += 1 + logger.warning( + "chat_update during stream failed (%d consecutive): %s", + self._consecutive_update_failures, + exc, + ) + # If updates keep failing on this ts (deleted, message-level rate + # limit, permission change), roll to a fresh message instead of + # burning cycles on the same broken ts until the buffer hits + # max_len. The accumulated buffer rides along into the new ts. + if self._consecutive_update_failures >= self.MAX_CONSECUTIVE_UPDATE_FAILURES: + self._consecutive_update_failures = 0 + self._roll_to_new_message(preserve_buffer=True) + + def _roll_to_new_message(self, preserve_buffer: bool = False) -> None: """Open a fresh placeholder message and reset the buffer. Used when - the fallback rolling update would overflow the per-message limit.""" + the fallback rolling update would overflow the per-message limit, + or when repeated chat_update failures make the current ts unusable. + + When `preserve_buffer` is True, the accumulated delta text is kept so + the next flush re-sends it against the new ts. This matters for the + consecutive-failure path: the deltas never reached Slack on the old + ts, so we can't just drop them. The size-overflow path passes + `preserve_buffer=False` because the old ts already received the full + buffer content via the `text=text` finalize update. + """ try: res = self.client.chat_postMessage( channel=self.channel, @@ -250,7 +278,8 @@ def _roll_to_new_message(self) -> None: text=self.placeholder, ) self.ts = res.get("ts") if isinstance(res, dict) else res["ts"] - self._buffer = "" + if not preserve_buffer: + self._buffer = "" except SlackApiError as exc: logger.warning("roll-to-new-message failed: %s", exc) @@ -379,9 +408,15 @@ def channel_allowed(channel: str, allowed_ids: list[str]) -> bool: def sanitize_error(exc: BaseException) -> str: """User-facing error text. Strips internal paths/tokens.""" msg = str(exc) or exc.__class__.__name__ - # Redact anything that looks like a Slack/OpenAI token. + # Redact tokens. Order matters: more specific patterns (sk-ant, sk-proj) + # run before the generic sk- pattern so the labels stay accurate. msg = re.sub(r"xox[abprs]-[A-Za-z0-9-]+", "[redacted-slack-token]", msg) + msg = re.sub(r"sk-ant-[A-Za-z0-9\-_]{10,}", "[redacted-anthropic-key]", msg) msg = re.sub(r"sk-[A-Za-z0-9\-_]{10,}", "[redacted-openai-key]", msg) + msg = re.sub(r"xai-[A-Za-z0-9\-_]{10,}", "[redacted-xai-key]", msg) + msg = re.sub(r"tvly-[A-Za-z0-9\-_]{10,}", "[redacted-tavily-key]", msg) + # AWS access keys: AKIA (long-term) / ASIA (temporary session). + msg = re.sub(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", "[redacted-aws-key]", msg) # Truncate stack-like paths. msg = re.sub(r"(/[\w./-]+\.py)", "[path]", msg) if len(msg) > 300: diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index d21c011..b0aa203 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -157,6 +157,43 @@ def test_sanitize_error_truncates_long(): assert len(out) <= 300 +def test_sanitize_error_redacts_anthropic_key(): + exc = ValueError("failed: sk-ant-api03-abc123xyz456_-_deadbeef") + out = sanitize_error(exc) + assert "sk-ant-api03" not in out + assert "redacted-anthropic-key" in out + # Must not also match the generic openai key pattern (order matters). + assert "redacted-openai-key" not in out + + +def test_sanitize_error_redacts_xai_key(): + exc = ValueError("grok call failed with xai-abcdef1234567890xyz") + out = sanitize_error(exc) + assert "xai-abcdef" not in out + assert "redacted-xai-key" in out + + +def test_sanitize_error_redacts_tavily_key(): + exc = ValueError("tavily error: tvly-abcdefghij1234567890") + out = sanitize_error(exc) + assert "tvly-abcdef" not in out + assert "redacted-tavily-key" in out + + +def test_sanitize_error_redacts_aws_access_key(): + exc = ValueError("Boto3 failure for AKIAIOSFODNN7EXAMPLE in region") + out = sanitize_error(exc) + assert "AKIAIOSFODNN7EXAMPLE" not in out + assert "redacted-aws-key" in out + + +def test_sanitize_error_redacts_aws_session_key(): + exc = ValueError("temp creds ASIA123456789ABCDEF0 expired") + out = sanitize_error(exc) + assert "ASIA123456789ABCDEF0" not in out + assert "redacted-aws-key" in out + + # --------------------------------------------------------------------------- # # StreamingMessage # --------------------------------------------------------------------------- # @@ -276,6 +313,63 @@ def test_streaming_message_fallback_rolls_when_buffer_exceeds_limit(): assert client.chat_update.called +def test_streaming_message_rolls_after_consecutive_update_failures(): + """If chat_update keeps failing on the current ts (deleted, permission, + message-level rate limit), the streamer must roll to a fresh ts instead + of looping forever. The accumulated buffer must be preserved so nothing + is lost.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "first"}, + {"ok": True, "ts": "rolled"}, + ] + client.chat_update.side_effect = SlackApiError( + "no", {"error": "message_not_found"} + ) + sm = StreamingMessage( + client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=10_000 + ) + sm.start() + # Three consecutive append -> flush -> chat_update failures. The third one + # should trigger a roll. + sm.append("alpha") + sm.append("beta") + sm.append("gamma") + assert sm.ts == "rolled" + # Buffer preserved: the deltas never reached Slack on the old ts. + assert "alpha" in sm._buffer + assert "beta" in sm._buffer + assert "gamma" in sm._buffer + # Failure counter reset after roll so the new ts gets a clean window. + assert sm._consecutive_update_failures == 0 + + +def test_streaming_message_resets_failure_counter_on_success(): + """A successful chat_update must reset the consecutive-failure counter.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "ts-a"} + call_count = {"n": 0} + + def _flaky_update(**_): + call_count["n"] += 1 + if call_count["n"] == 1: + raise SlackApiError("transient", {"error": "ratelimited"}) + return {"ok": True} + + client.chat_update.side_effect = _flaky_update + sm = StreamingMessage( + client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=10_000 + ) + sm.start() + sm.append("one") # fails + sm.append("two") # succeeds, counter resets + assert sm._consecutive_update_failures == 0 + # Still on original ts (no roll) because we only saw 1 failure. + assert sm.ts == "ts-a" + + def test_streaming_message_stop_splits_long_final(): """A final_text longer than max_len must land in multiple messages.""" client = MagicMock() From dcac908ad437cb3e197ac3f59a1507a3ff30bba9 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:27:05 +0900 Subject: [PATCH 47/65] perf: make ConversationStore.truncate_to_chars single-pass Old implementation re-serialized the full kept list on every pop(0), giving quadratic behavior in message count. Now we serialize each message once, then walk backwards from newest accumulating exact size (including JSON array brackets and ", " item separators) until the budget would be exceeded. Added a regression test that pins output size to the real len(json.dumps(kept)) across a range of budgets, so future rewrites can't drift off the exact separator accounting. --- src/dedup.py | 29 ++++++++++++++++++++++++----- tests/test_dedup.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/dedup.py b/src/dedup.py index c42365b..99aa633 100644 --- a/src/dedup.py +++ b/src/dedup.py @@ -125,12 +125,31 @@ def put( except ClientError as exc: logger.warning("conversation put failed: %s", exc) + # json.dumps with default separators renders a list as `[item, item, ...]` + # so exact serialized size is: 2 (brackets) + sum(sizes) + 2 * (n - 1) (", ") + _JSON_ARRAY_BRACKETS = 2 + _JSON_ITEM_SEPARATOR = 2 + @staticmethod def truncate_to_chars(messages: list[dict[str, Any]], max_chars: int) -> list[dict[str, Any]]: - """Drop oldest messages until total serialized size <= max_chars.""" + """Drop oldest messages until total serialized size <= max_chars. + + Previous implementation was O(n²) — it re-serialized the full kept + list on every pop. This walks the list once, serializes each message + individually, then accumulates from the newest end backwards until + adding the next (older) message would exceed the budget. Matches the + exact byte count of `json.dumps(kept, ensure_ascii=False)` using the + default item separator `", "`. + """ if not messages: return [] - out = list(messages) - while out and len(json.dumps(out, ensure_ascii=False)) > max_chars: - out.pop(0) - return out + sizes = [len(json.dumps(m, ensure_ascii=False)) for m in messages] + total = ConversationStore._JSON_ARRAY_BRACKETS + start = len(messages) # exclusive; empty kept set serializes to "[]" + for i in range(len(messages) - 1, -1, -1): + add_cost = sizes[i] + (ConversationStore._JSON_ITEM_SEPARATOR if start < len(messages) else 0) + if total + add_cost > max_chars: + break + total += add_cost + start = i + return messages[start:] diff --git a/tests/test_dedup.py b/tests/test_dedup.py index f74e420..d2238c4 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -116,3 +116,38 @@ def test_conversation_truncate_helper_direct(): import json assert len(json.dumps(trimmed, ensure_ascii=False)) <= 1200 assert len(trimmed) < len(msgs) + + +def test_conversation_truncate_keeps_newest_messages(): + """Truncation drops the oldest entries — the most recent turn must survive + as long as it fits.""" + msgs = [ + {"role": "user", "content": f"msg-{i}"} + for i in range(20) + ] + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=200) + assert trimmed, "should keep at least some messages" + # The newest message must be in the kept suffix. + assert trimmed[-1]["content"] == "msg-19" + + +def test_conversation_truncate_budget_matches_exact_dumps_length(): + """The fast cumulative-size algorithm must agree with the naive + json.dumps(kept) size, within a single byte.""" + import json + + msgs = [{"role": "user", "content": "a" * 17 + str(i)} for i in range(8)] + for budget in (50, 80, 120, 200, 300, 500, 1000, 5000): + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=budget) + assert len(json.dumps(trimmed, ensure_ascii=False)) <= budget, ( + f"budget={budget}, kept={len(trimmed)}, " + f"actual={len(json.dumps(trimmed, ensure_ascii=False))}" + ) + + +def test_conversation_truncate_single_large_msg_overflows_budget(): + """If every individual message exceeds the budget, return an empty list + rather than partial garbage.""" + msgs = [{"role": "user", "content": "x" * 1000}] + trimmed = ConversationStore.truncate_to_chars(msgs, max_chars=50) + assert trimmed == [] From b7229806e1fd81d0e20c7150bf6b19df205e0110 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:27:16 +0900 Subject: [PATCH 48/65] fix: bypass channel allowlist for DMs and drop empty mentions early Two flow fixes in _process: - DMs (is_dm=True) no longer go through channel_allowed. DM channel IDs are D-prefixed and not normally in ALLOWED_CHANNEL_IDS, so setting the allowlist for a team used to lock every user out of the direct-message path the moment the var was configured. Slack's install permission already gates who can open the DM with the bot. - Bare "@bot" with no prompt now returns before DedupStore.reserve. The previous ordering reserved a 1-hour TTL row for every empty ping and made Slack retries of the same no-op register as dedup.skip noise. --- app.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index d221319..2abe50d 100644 --- a/app.py +++ b/app.py @@ -143,6 +143,13 @@ def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 thread_ts = event.get("thread_ts") or event.get("ts") user = event.get("user", "") + # Drop empty mentions (bare "@bot" with no prompt) BEFORE reserving a + # dedup slot. Otherwise every empty ping burns a 1h TTL row on the + # dedup table and, for no-op messages, shows up as a dedup.skip on any + # Slack retry even though there was never anything to do. + if not text: + return + dedup = _get_dedup() dedup_key = event.get("client_msg_id") or f"{channel}:{event.get('ts')}" try: @@ -152,7 +159,12 @@ def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 except Exception as exc: # noqa: BLE001 logger.warning("dedup unavailable, proceeding without it: %s", exc) - if not channel_allowed(channel, settings.allowed_channel_ids): + # Channel allowlist applies to public/private channels only. DMs use + # per-channel IDs (D-prefix) that aren't normally enrolled in the + # allowlist — enforcing there would lock out every user's direct-message + # path the moment an operator sets ALLOWED_CHANNEL_IDS. Slack's own + # workspace install permission already gates who can open the DM. + if not is_dm and not channel_allowed(channel, settings.allowed_channel_ids): msg = settings.allowed_channel_message or "" if msg: say(text=msg, thread_ts=thread_ts) @@ -169,9 +181,6 @@ def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 log_event(logger, "throttle.limit", user=user, active=active) return - if not text: - return - # Show a typing-style status indicator while the bot is "working" with # nothing to reply yet. We intentionally do NOT post a placeholder # chat.postMessage up front: that would render as a separate UI element From f51de648ed96758d981a94179ad38733b40cc096 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:27:24 +0900 Subject: [PATCH 49/65] chore: drop unused AWS_REGION_NAME from Lambda env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in src/ reads AWS_REGION_NAME — config.py pulls the region from AWS_REGION, which the Lambda runtime auto-injects and cannot be overridden via serverless's environment block. The alias was dead config left over from an earlier attempt to work around the reserved name; removed and replaced with a comment so the next contributor doesn't re-introduce it. --- serverless.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/serverless.yml b/serverless.yml index ee7272c..76fb9fd 100644 --- a/serverless.yml +++ b/serverless.yml @@ -19,7 +19,10 @@ provider: katalog_id: "858" environment: # --- runtime-internal (serverless-provided, not from CI env) --- - AWS_REGION_NAME: ${self:provider.region} + # NOTE: AWS_REGION is auto-injected by the Lambda runtime and is a + # reserved name we can't override here — src/config.py reads it directly + # via os.getenv("AWS_REGION", ...). Do not add an AWS_REGION_NAME alias + # here; no code reads it, so it was removed as dead config. DYNAMODB_TABLE_NAME: ${self:service}-${sls:stage} # --- secrets: Slack --- SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} From 2af08742a939734007ba2bbf4e555135fecdcc47 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 15:27:33 +0900 Subject: [PATCH 50/65] docs: sync test counts, coverage, and new invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test suite grew from 161 to 189 after the audit follow-ups; per-module coverage moved too (dedup 78→80, slack_helpers 84→86, bedrock 76→78, tools/slack 86→87). Update both CLAUDE.md and README.md so onboarding doesn't trust stale numbers. Also extend CLAUDE.md's "Things that are easy to break" with the new load-bearing invariants the fixes in this series rely on: Nova vision must family-route, agent.run() must close its owned executor, tool exception catch must stay broad enough to wrap provider SDK errors, and channel allowlist must skip DMs. --- CLAUDE.md | 12 ++++++++---- README.md | 6 +++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 16ecd05..9434b15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:aws ## Testing -161 tests, 89% overall coverage. `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: +189 tests, 89% overall coverage. `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: - Tests mirror source layout: `tests/llms/` for each `src/llms/*` submodule, `tests/tools/` for each `src/tools/*` submodule. Top-level `tests/test_agent.py`, `test_config.py`, `test_dedup.py`, `test_logging_utils.py`, `test_slack_helpers.py` cover the non-packaged modules. - Shared tool-test fixtures (`_ctx`, `_settings`, `_streamed_read`) live in `tests/tools/_helpers.py` — individual test files import from there instead of redefining them. @@ -169,9 +169,9 @@ Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:aws Per-module coverage: -- `agent.py` 96%, `config.py` 98%, `dedup.py` 78%, `slack_helpers.py` 83%, `logging_utils.py` 68% -- `llms/`: `base.py` 70%, `openai_wire.py` 96%, `openai.py` 100%, `xai.py` 100%, `bedrock.py` 76%, `composite.py` 87%, `factory.py` 94% -- `tools/`: `registry.py` 100%, `slack.py` 86%, `search.py` 93%, `web.py` 97%, `image.py` 100%, `time.py` 100% +- `agent.py` 96%, `config.py` 98%, `dedup.py` 80%, `slack_helpers.py` 86%, `logging_utils.py` 68% +- `llms/`: `base.py` 70%, `openai_wire.py` 96%, `openai.py` 100%, `xai.py` 100%, `bedrock.py` 78%, `composite.py` 87%, `factory.py` 94% +- `tools/`: `registry.py` 100%, `slack.py` 87%, `search.py` 93%, `web.py` 97%, `image.py` 100%, `time.py` 100% ## Things that are easy to break @@ -184,6 +184,10 @@ Per-module coverage: - **Removing the SSRF guard (`_validate_public_https_url`) on `fetch_webpage`** re-opens fetch to RFC1918 space and cloud-metadata endpoints (e.g. `169.254.169.254`). - **Enabling redirects on the `fetch_webpage` raw fallback** — a 302 to a private host bypasses the pre-flight DNS check; keep `_NoRedirectHandler` installed. - **DNS rebinding on `fetch_webpage` raw fallback**. The pre-flight `getaddrinfo` check and the eventual TCP connect are two separate DNS lookups; a TTL=0 attacker can flip between them. Lambda's environment makes the attack hard and impact is bounded (no VPC by default), but don't treat `_validate_public_https_url` as a guarantee that the actual connection hits the same IP. If you ever add VPC/private-subnet egress, revisit this. +- **Dropping the Nova branch in `BedrockProvider.describe_image`**. Nova chat models speak the Converse API with an `image` content block — sending Claude's Messages body at a Nova model ID fails with `ValidationException`. `chat()` already family-routes; the vision entrypoint must do the same. +- **Removing `SlackMentionAgent`'s `finally: self.executor.close()`**. The agent creates its own `ToolExecutor` (and hence a `ThreadPoolExecutor`) unless one is injected. Without the close, every Lambda warm invocation adds new non-daemon workers to the process registry that never unwind until interpreter exit. +- **Narrowing `ToolExecutor.execute`'s exception catch back to a stdlib allowlist**. Provider SDKs raise their own (`openai.APIError`, `anthropic.APIError`, `httpx.HTTPError`) that don't inherit from `ValueError`/`TypeError`; when they escape the executor the whole agent loop aborts instead of handing the failure back to the LLM as `{"ok": False, ...}` for recovery. +- **Applying channel allowlist to DMs**. `_process()` skips `channel_allowed` when `is_dm=True` — DM channel IDs are D-prefixed and not normally in `ALLOWED_CHANNEL_IDS`, so enforcing there would instantly lock out every user's direct-message path the moment an operator set a channel allowlist. ## Excluded (Phase 2+) diff --git a/README.md b/README.md index dc60a67..c1b69b8 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA | `RESPONSE_LANGUAGE` | | `ko` | `ko` / `en` | | `DYNAMODB_TABLE_NAME` | | `lambda-gurumi-bot-dev` | dedup / 대화 저장 테이블 | | `AWS_REGION` | | `us-east-1` | AWS 리전 | -| `ALLOWED_CHANNEL_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 채널 허용 | -| `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 | +| `ALLOWED_CHANNEL_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 채널 허용. **DM(`message.im`) 은 허용 리스트 대상이 아님** — allowlist 를 설정해도 DM 경로는 항상 허용됨 | +| `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 (DM 에는 적용되지 않음) | | `MAX_LEN_SLACK` | | `3000` | 메시지 분할 기준 (≥500). `.env.example` · `serverless.yml` 기본 `3000`, 미지정 시 `config.py` 폴백 `2000`. | | `MAX_OUTPUT_TOKENS` | | `4096` | LLM hop 당 출력 토큰 상한 (≥256) | | `MAX_THROTTLE_COUNT` | | `100` | 유저별 동시 요청 상한 | @@ -101,7 +101,7 @@ python localtest.py --no-stream "React 훅 설명해줘" # 전체 답변을 python localtest.py --quiet-steps "…" # 중간 step 로그 숨김 python localtest.py # 대화형 (stdin, Ctrl+D) -# 테스트 (161 테스트, 커버리지 89% — `pytest.ini` 기준) +# 테스트 (189 테스트, 커버리지 89% — `pytest.ini` 기준) python -m pytest --cov=src --cov-report=term-missing python -m pytest tests/llms/test_bedrock.py -v # 패키지 단위 python -m pytest tests/tools/test_web.py::test_fetch_webpage_jina_happy_path -v # 단일 케이스 From 7d9f04dc9eec7adb14fde9010db16fb66f08a3f8 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 17:49:44 +0900 Subject: [PATCH 51/65] feat: layer system prompt and harden its guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously SYSTEM_MESSAGE overrode the entire default base prompt. An operator who set it to a persona-style string (e.g. "한국어로 답하세요") silently deleted the loop's planning / tool-use / attachment-lookup guidance — which made ambiguous attachment requests need 3 hops to resolve and let GitHub-markdown and tool-failure patterns slip through. Split the system prompt into three append-only layers: - Task rules (code-owned, always present): plan work, parallel tool_calls, and tool-failure policy (ok:false → tell user briefly + suggest alternative, no blind retry, no fabrication). - Operator policy: SYSTEM_MESSAGE is now appended, not substituted. - Persona: new PERSONA_MESSAGE env carries answer style / tone. Add three targeted guidances on top: - Slack mrkdwn rules so the streaming fallback path doesn't surface raw `**bold**` or `[label](url)` to users (enable_native defaults off so Slack receives a plain `text` field). - Speculative attachment lookup for ambiguous references ("이 사진", "아까 그 파일") — read_attached_* returns [] cheaply when the event has no file, signaling the fetch_thread_history fallback. - Parallel tool_calls hint to avoid hop waste when independent tools are needed together. Wire PERSONA_MESSAGE through config, app, serverless, push.yml, and .env.example. Update README env table and add regression tests asserting task rules survive a set SYSTEM_MESSAGE and the three new guidances stay in the prompt. --- .env.example | 3 +- .github/workflows/push.yml | 1 + README.md | 5 +-- app.py | 1 + serverless.yml | 1 + src/agent.py | 68 ++++++++++++++++++++++++++++++++++---- src/config.py | 3 ++ tests/test_agent.py | 64 +++++++++++++++++++++++++++++++++++ tests/test_config.py | 18 +++++++++- 9 files changed, 154 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 34aeffd..2e9264d 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,8 @@ RESPONSE_LANGUAGE="ko" # ko | en # --- Slack UX --- BOT_CURSOR=":robot_face:" MAX_LEN_SLACK=3000 # per-message character cap, >=500 -SYSTEM_MESSAGE="" # optional base system prompt override +SYSTEM_MESSAGE="" # optional extra operator policy appended to task rules +PERSONA_MESSAGE="" # optional answer style / tone (e.g. "자연스러운 한국어로 핵심부터 답한다") # --- Access control / throttle --- ALLOWED_CHANNEL_IDS="" # comma-separated channel IDs, empty = allow all diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 93c71fa..d66d9d4 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -70,6 +70,7 @@ jobs: BOT_CURSOR: ${{ vars.BOT_CURSOR }} MAX_LEN_SLACK: ${{ vars.MAX_LEN_SLACK }} SYSTEM_MESSAGE: ${{ vars.SYSTEM_MESSAGE }} + PERSONA_MESSAGE: ${{ vars.PERSONA_MESSAGE }} # --- vars: Access control / throttle --- ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} diff --git a/README.md b/README.md index c1b69b8..6b49c28 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA | `MAX_WEB_LINKS` | | `20` | `fetch_webpage` 반환 링크 최대 개수 (≥0) | | `JINA_READER_BASE` | | `https://r.jina.ai` | `fetch_webpage` 가 호출하는 Jina Reader 베이스 URL. `https://` 가 아니면 기본값으로 폴백 | | `BOT_CURSOR` | | `:robot_face:` | 플레이스홀더·스트림 인디케이터 이모지 | -| `SYSTEM_MESSAGE` | | — | 시스템 프롬프트 오버라이드 | +| `SYSTEM_MESSAGE` | | — | 작업 규칙에 append 되는 추가 운영 정책 (예: 조직·채널 제약). base 를 덮어쓰지 않음 | +| `PERSONA_MESSAGE` | | — | 답변 스타일/톤 (예: `"자연스러운 한국어로 핵심부터 답한다"`) | | `LOG_LEVEL` | | `INFO` | 로그 레벨 | ## 모델 매트릭스 @@ -127,7 +128,7 @@ aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${AC ### 2. GitHub 저장소 설정 - **Secrets**: `AWS_ACCOUNT_ID`, `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `OPENAI_API_KEY`, `XAI_API_KEY`(xAI 사용 시), `TAVILY_API_KEY`(선택) -- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `SYSTEM_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES`, `MAX_WEB_CHARS`, `MAX_WEB_BYTES`, `MAX_WEB_LINKS`, `JINA_READER_BASE` +- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `SYSTEM_MESSAGE`, `PERSONA_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES`, `MAX_WEB_CHARS`, `MAX_WEB_BYTES`, `MAX_WEB_LINKS`, `JINA_READER_BASE` ### 3. 배포 diff --git a/app.py b/app.py index 2abe50d..1557b44 100644 --- a/app.py +++ b/app.py @@ -250,6 +250,7 @@ def _on_step(step_num: int, phase: str, detail: dict) -> None: max_steps=settings.agent_max_steps, response_language=settings.response_language, system_message=settings.system_message, + persona_message=settings.persona_message, history=history, on_stream=_on_stream_wrapped, on_step=_on_step, diff --git a/serverless.yml b/serverless.yml index 76fb9fd..60bffe5 100644 --- a/serverless.yml +++ b/serverless.yml @@ -45,6 +45,7 @@ provider: BOT_CURSOR: ${env:BOT_CURSOR, ':robot_face:'} MAX_LEN_SLACK: ${env:MAX_LEN_SLACK, '3000'} SYSTEM_MESSAGE: ${env:SYSTEM_MESSAGE, ''} + PERSONA_MESSAGE: ${env:PERSONA_MESSAGE, ''} # --- vars: Access control / throttle --- ALLOWED_CHANNEL_IDS: ${env:ALLOWED_CHANNEL_IDS, ''} ALLOWED_CHANNEL_MESSAGE: ${env:ALLOWED_CHANNEL_MESSAGE, ''} diff --git a/src/agent.py b/src/agent.py index 16074b3..9bb7232 100644 --- a/src/agent.py +++ b/src/agent.py @@ -40,6 +40,7 @@ def __init__( tool_executor: ToolExecutor | None = None, response_language: str = "ko", system_message: str | None = None, + persona_message: str | None = None, history: list[dict[str, Any]] | None = None, on_stream: Callable[[str], None] | None = None, on_step: Callable[[int, str, dict[str, Any]], None] | None = None, @@ -55,6 +56,7 @@ def __init__( self._owns_executor = tool_executor is None self.response_language = response_language self.system_message = system_message + self.persona_message = persona_message self.history = history or [] self.on_stream = on_stream # on_step(step_num, phase, detail) — phases: "tool_use", "tool_result", "compose" @@ -158,19 +160,73 @@ def _run(self, user_message: str) -> AgentResult: # ------------------------------------------------------------------ # def _build_system_prompt(self) -> str: - base = ( - self.system_message - or "You are an assistant for Slack mention requests. Plan work, call tools when needed, and provide concise helpful answers." + """Assemble the system prompt from three layers: + + 1. Task rules (owned by code, always present) — how to plan, call + tools, render Slack replies, look up attachments. + 2. Operator policy (`SYSTEM_MESSAGE`, optional) — extra organization + or channel-specific policy appended on top of task rules. + 3. Persona (`PERSONA_MESSAGE`, optional) — answer style / tone. + + The language directive is re-emphasized at the very end so the + model does not drift even if persona is in a different language. + """ + # Layer 1 — task rules. Fixed in code so operators cannot accidentally + # delete the loop's planning / parallel-call / tool guidance by + # overriding SYSTEM_MESSAGE. + task_rules = ( + "You are an assistant for Slack mention requests. Plan work, " + "call tools when needed, and provide concise helpful answers. " + "When multiple independent tools are required, emit their " + "tool_calls in parallel within a single turn instead of running " + "them one-by-one. If a tool returns `ok:false`, tell the user " + "briefly what failed (one short line) and, when it makes sense, " + "suggest an alternative — do not retry blindly with the same " + "arguments and do not fabricate a result." ) - # Slack-specific rendering rules. + # Slack rendering rules. The streaming fallback path posts via plain + # chat.postMessage / chat.update with a `text` field, which Slack + # renders as mrkdwn — NOT GitHub markdown. Guide the model so replies + # don't surface raw `**bold**` or `[text](url)` strings. slack_rules = ( "When you call the `generate_image` tool, the generated image is " "already uploaded inline into the Slack thread. Do NOT repeat the " "image URL or permalink in your text reply — just describe or " "caption the image briefly. The user sees the picture attached " - "directly; a URL line is duplicate noise." + "directly; a URL line is duplicate noise.\n" + "Slack renders mrkdwn, not GitHub markdown. Use `*bold*` with " + "single asterisks, `_italic_`, `` `code` ``, and " + "`` for links. Do NOT use `**bold**` or " + "`[label](url)` — those appear as raw text in Slack." ) - return f"{base}\n\n{slack_rules}\n\nRespond in language: {self.response_language}." + attachment_rules = ( + "If the user asks about an image or document in the current " + "thread, call `read_attached_images` / `read_attached_document` " + "first — they target files attached to the triggering message. " + "If the result is an empty list, the attachment lives on an " + "earlier message: call `fetch_thread_history`, take the " + "`url_private_download` values from that message's `files`, " + "then call `read_attached_images(urls=[...])` or " + "`read_attached_document(urls=[...])` with them. When the user's " + "reference to a file is ambiguous (e.g. '이 사진', '아까 그 " + "파일'), call `read_attached_images` (or " + "`read_attached_document`) speculatively first — it returns an " + "empty list cheaply when no file is attached to the current " + "message, and that signals you to fall back to " + "`fetch_thread_history`. Never guess or fabricate file URLs." + ) + sections = [task_rules, slack_rules, attachment_rules] + + # Layer 2 — operator policy (optional, append). + if self.system_message: + sections.append(f"Additional policy:\n{self.system_message}") + + # Layer 3 — persona / answer style (optional, append). + if self.persona_message: + sections.append(f"Response style:\n{self.persona_message}") + + sections.append(f"Respond in language: {self.response_language}.") + return "\n\n".join(sections) @staticmethod def _call_signature(call: ToolCall) -> str: diff --git a/src/config.py b/src/config.py index 2f96eb4..9cf657d 100644 --- a/src/config.py +++ b/src/config.py @@ -102,6 +102,7 @@ class Settings: max_output_tokens: int = 4096 bot_cursor: str = ":robot_face:" system_message: str | None = None + persona_message: str | None = None tavily_api_key: str | None = None xai_api_key: str | None = None log_level: str = "INFO" @@ -124,6 +125,7 @@ def from_env(cls) -> "Settings": ) response_language = _enum_env("RESPONSE_LANGUAGE", "ko", _VALID_LANGUAGES) system_message = os.getenv("SYSTEM_MESSAGE", "").strip() or None + persona_message = os.getenv("PERSONA_MESSAGE", "").strip() or None tavily_key = os.getenv("TAVILY_API_KEY", "").strip() or None xai_key = os.getenv("XAI_API_KEY", "").strip() or None return cls( @@ -145,6 +147,7 @@ def from_env(cls) -> "Settings": max_output_tokens=_int_env("MAX_OUTPUT_TOKENS", 4096, minimum=256), bot_cursor=os.getenv("BOT_CURSOR", ":robot_face:").strip() or ":robot_face:", system_message=system_message, + persona_message=persona_message, tavily_api_key=tavily_key, xai_api_key=xai_key, log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", diff --git a/tests/test_agent.py b/tests/test_agent.py index a4b8d9a..a52e3fd 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -287,6 +287,70 @@ def test_agent_does_not_close_injected_executor(): ext_exec.close() +def test_system_prompt_keeps_task_rules_even_when_system_message_set(): + """SYSTEM_MESSAGE must NOT replace the task rules — it is appended as + additional policy so the agent's planning / tool / attachment guidance + always stays in the prompt.""" + reg = _registry_with_search() + agent = SlackMentionAgent( + llm=MagicMock(), + context=_ctx(), + registry=reg, + max_steps=3, + system_message="Do not expose secrets.", + persona_message="자연스러운 한국어로 핵심부터 답한다.", + ) + prompt = agent._build_system_prompt() + # Layer 1: task + slack + attachment rules are always present. + assert "Plan work, call tools when needed" in prompt + assert "in parallel within a single turn" in prompt + assert "generate_image" in prompt + assert "fetch_thread_history" in prompt + # Layer 2 / 3 labels present and carry the operator-supplied text. + assert "Additional policy:" in prompt + assert "Do not expose secrets." in prompt + assert "Response style:" in prompt + assert "자연스러운 한국어로 핵심부터 답한다." in prompt + # Language re-emphasis remains last. + assert prompt.rstrip().endswith("Respond in language: ko.") + + +def test_system_prompt_includes_tier_s_guidance(): + """The three Tier-S guidances must stay in the prompt: + (H1) Slack mrkdwn rendering, (H2) speculative attachment lookup on + ambiguous references, (H3) tool-failure response policy.""" + reg = _registry_with_search() + agent = SlackMentionAgent( + llm=MagicMock(), context=_ctx(), registry=reg, max_steps=3 + ) + prompt = agent._build_system_prompt() + # H1: mrkdwn vs GitHub markdown guidance. + assert "mrkdwn" in prompt + assert "`*bold*`" in prompt + assert "`**bold**`" in prompt # the anti-pattern is called out + assert "" in prompt + # H2: speculative attachment call on ambiguous reference. + assert "speculatively" in prompt + assert "이 사진" in prompt + # H3: tool-failure policy. + assert "ok:false" in prompt + assert "do not retry blindly" in prompt + assert "do not fabricate" in prompt + + +def test_system_prompt_omits_empty_optional_layers(): + """When SYSTEM_MESSAGE / PERSONA_MESSAGE are unset, their labeled + blocks must not appear in the prompt.""" + reg = _registry_with_search() + agent = SlackMentionAgent( + llm=MagicMock(), context=_ctx(), registry=reg, max_steps=3 + ) + prompt = agent._build_system_prompt() + assert "Additional policy:" not in prompt + assert "Response style:" not in prompt + assert "Plan work" in prompt + + def test_agent_aggregates_token_usage(): reg = _registry_with_search() llm = ScriptedLLM( diff --git a/tests/test_config.py b/tests/test_config.py index a8582c7..8d0d9d1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -19,7 +19,7 @@ def _clear_env(monkeypatch): "IMAGE_PROVIDER", "IMAGE_MODEL", "OPENAI_API_KEY", "RESPONSE_LANGUAGE", "AGENT_MAX_STEPS", "DYNAMODB_TABLE_NAME", "AWS_REGION", "ALLOWED_CHANNEL_IDS", "ALLOWED_CHANNEL_MESSAGE", "MAX_LEN_SLACK", "MAX_THROTTLE_COUNT", - "MAX_HISTORY_CHARS", "BOT_CURSOR", "SYSTEM_MESSAGE", "TAVILY_API_KEY", "XAI_API_KEY", "LOG_LEVEL", + "MAX_HISTORY_CHARS", "BOT_CURSOR", "SYSTEM_MESSAGE", "PERSONA_MESSAGE", "TAVILY_API_KEY", "XAI_API_KEY", "LOG_LEVEL", "DEFAULT_TIMEZONE", "MAX_DOC_CHARS", "MAX_DOC_PAGES", "MAX_DOC_BYTES", "MAX_WEB_CHARS", "MAX_WEB_BYTES", "MAX_WEB_LINKS", "JINA_READER_BASE", ]: @@ -92,6 +92,22 @@ def test_require_slack_credentials_ok(monkeypatch, reload_config): s.require_slack_credentials() # no raise +def test_persona_and_system_messages_default_none(monkeypatch, reload_config): + _clear_env(monkeypatch) + s = reload_config() + assert s.system_message is None + assert s.persona_message is None + + +def test_persona_and_system_messages_from_env(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("SYSTEM_MESSAGE", "Do not expose secrets.") + monkeypatch.setenv("PERSONA_MESSAGE", "자연스러운 한국어로 핵심부터 답한다.") + s = reload_config() + assert s.system_message == "Do not expose secrets." + assert s.persona_message == "자연스러운 한국어로 핵심부터 답한다." + + def test_xai_provider_is_a_valid_enum_value(monkeypatch, reload_config): _clear_env(monkeypatch) monkeypatch.setenv("LLM_PROVIDER", "xai") From bb32da904389b5fabd584be91ed5b28c0288534a Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 21 Apr 2026 17:54:47 +0900 Subject: [PATCH 52/65] feat: add additional API Gateway permissions for policy management --- .github/aws-role/role-policy.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/aws-role/role-policy.json b/.github/aws-role/role-policy.json index dbef3e3..1d96557 100644 --- a/.github/aws-role/role-policy.json +++ b/.github/aws-role/role-policy.json @@ -119,7 +119,10 @@ "apigateway:POST", "apigateway:PUT", "apigateway:PATCH", - "apigateway:DELETE" + "apigateway:DELETE", + "apigateway:UpdateRestApiPolicy", + "apigateway:GetRestApiPolicy", + "apigateway:DeleteRestApiPolicy" ], "Resource": [ "arn:aws:apigateway:*::/restapis", From b554c774997d23c153dadda931da230a2dc89de3 Mon Sep 17 00:00:00 2001 From: nalbam Date: Thu, 23 Apr 2026 00:40:43 +0900 Subject: [PATCH 53/65] chore: update AWS credentials action to v6 in workflow files --- .github/workflows/push.yml | 4 ++-- .github/workflows/sync-awsdocs.yml | 2 +- .github/workflows/sync-notion.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d66d9d4..fd9947d 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Node.js 🟢 uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24" - name: Install Python deps 📦 run: | @@ -42,7 +42,7 @@ jobs: npm install serverless-python-requirements - name: Configure AWS credentials (OIDC) 🔑 - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/lambda-gurumi-bot aws-region: us-east-1 diff --git a/.github/workflows/sync-awsdocs.yml b/.github/workflows/sync-awsdocs.yml index 2b7a8e5..bbf09da 100644 --- a/.github/workflows/sync-awsdocs.yml +++ b/.github/workflows/sync-awsdocs.yml @@ -31,7 +31,7 @@ jobs: run: sudo apt-get install -y qpdf - name: Configure AWS credentials (OIDC) 🔑 - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" role-session-name: github-actions-sync-awsdocs diff --git a/.github/workflows/sync-notion.yml b/.github/workflows/sync-notion.yml index 5ba0a07..1c1a9e7 100644 --- a/.github/workflows/sync-notion.yml +++ b/.github/workflows/sync-notion.yml @@ -44,7 +44,7 @@ jobs: run: python scripts/notion/export.py - name: Configure AWS credentials (OIDC) 🔑 - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: "arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ROLE_NAME }}" role-session-name: github-actions-sync-notion From fc4eaa2fbdb5add9d0df4dd0affc9a8da19b8bab Mon Sep 17 00:00:00 2001 From: nalbam Date: Thu, 23 Apr 2026 01:01:48 +0900 Subject: [PATCH 54/65] chore: raise Lambda and image tool timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend Lambda timeout from 90s to 300s and the generate_image tool from 75s to 240s. gpt-image-2 routinely takes 60-180s and was tripping the 75s tool cap, surfacing as "이미지 생성 중 시간 초과가 발생했습니다" to users. Output flows via Slack Web API (chat_postMessage, files_upload_v2), so the API Gateway 29s integration limit is independent of Lambda runtime. Slack's retry burst from the 29s 504 is already absorbed by DedupStore, so extending Lambda runtime is safe for end-user delivery. --- CLAUDE.md | 2 +- serverless.yml | 2 +- src/tools/image.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9434b15..59f2044 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,7 +139,7 @@ Neither extension requires editing the registry or the agent loop. ## Deployment `serverless.yml` provisions: -- Lambda: python3.12, x86_64, 5120MB, 90s timeout. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) +- Lambda: python3.12, x86_64, 5120MB, 300s timeout. The 300s value is well beyond the API Gateway REST integration timeout of 29s, but that's fine here — Lambda emits replies via the Slack Web API (`chat_postMessage`, `files_upload_v2`), not through the HTTP response that triggered the invocation, so the API Gateway 504 at 29s doesn't matter as long as dedup absorbs Slack's retry burst. Tool timeouts (e.g. `generate_image` 240s) are sized so that compose + upload + history-save fit in the remaining budget. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) - DynamoDB: hash `id`, GSI `user-index` (user + expire_at, KEYS_ONLY), TTL `expire_at`. - IAM (runtime Lambda role): `dynamodb:GetItem/PutItem/Query` on table + GSI, `bedrock:InvokeModel*`/`Converse*`. diff --git a/serverless.yml b/serverless.yml index 60bffe5..e5ac0b1 100644 --- a/serverless.yml +++ b/serverless.yml @@ -12,7 +12,7 @@ provider: region: ${env:AWS_REGION, 'us-east-1'} stage: ${opt:stage, 'dev'} memorySize: 5120 - timeout: 90 + timeout: 300 stackTags: katalog_id: "858" tags: diff --git a/src/tools/image.py b/src/tools/image.py index f26d63a..d740cb0 100644 --- a/src/tools/image.py +++ b/src/tools/image.py @@ -14,7 +14,7 @@ "properties": {"prompt": {"type": "string"}}, "required": ["prompt"], }, - timeout=75.0, # gpt-image-1 / titan / stability can take 30–60s + timeout=240.0, # gpt-image-2 / titan / stability can take 60–180s; Lambda caps at 300s, leaves ~60s for compose + upload ) def generate_image(ctx: ToolContext, prompt: str) -> dict[str, str]: image_bytes = ctx.llm.generate_image(prompt) From 01041c61d94d57828efa2956cacaba9491a4fc4d Mon Sep 17 00:00:00 2001 From: nalbam Date: Thu, 23 Apr 2026 01:15:06 +0900 Subject: [PATCH 55/65] refactor: split Lambda into receiver/worker paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-running agent calls (notably generate_image with gpt-image-2) could exceed the API Gateway 29s integration timeout, which caused Slack to record a delivery failure and issue retries that raced against the real reply. The receiver path now fires a fire-and-forget lambda:Invoke (InvocationType=Event) against this same function with _worker=True and returns HTTP 200 within a few hundred ms. The worker path picks up the payload in a separate Lambda invocation and runs _process to completion inside the full 300s timeout. The existing dedup key still absorbs Slack's retries on the receiver side and Lambda async's built-in 2x retry on worker failure — all paths converge on the same dedup:{msg_id} row. Adds lambda:InvokeFunction on self to the runtime IAM role, a WebClient-from-token rebuild inside the worker (Bolt's injected client dies with the receiver process), and an inline-execution fallback so localtest / pytest paths keep working without AWS_LAMBDA_FUNCTION_NAME. --- CLAUDE.md | 17 ++- app.py | 115 +++++++++++++++-- serverless.yml | 8 ++ tests/test_app.py | 320 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 tests/test_app.py diff --git a/CLAUDE.md b/CLAUDE.md index 59f2044..68ce8c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,9 +96,16 @@ Image generation is family-routed too: Titan/Nova-Canvas use `TEXT_IMAGE` task; `_CompositeProvider` wraps two providers when text and image providers differ (e.g., OpenAI text + Bedrock image). +### Receiver / worker split via Lambda async self-invoke + +`lambda_handler` routes one of two ways based on the event shape: + +- **Receiver path** (Slack → API Gateway → Lambda): Bolt verifies the Slack signature and dispatches to the `app_mention` / `message` handlers. Each handler `ack()`s, then calls `_enqueue_worker(event, is_dm)` which issues a single `lambda:Invoke` against this same function with `InvocationType=Event` and a `{"_worker": True, ...}` payload, then returns. The receiver path is back to HTTP 200 within a few hundred ms. That's why Lambda timeout (`serverless.yml: timeout: 300`) can be much larger than the API Gateway REST integration limit (29s) — the receiver never runs long enough to care, and the HTTP response is already home before the worker even starts. Fallback: if `AWS_LAMBDA_FUNCTION_NAME` is unset (local harness) or `lambda.invoke` raises, `_enqueue_worker` runs `_process_worker` inline so the message isn't dropped. +- **Worker path** (Lambda async self-invoke): `lambda_handler` short-circuits on `event["_worker"] is True` and calls `_process_worker`, which rebuilds a `WebClient` from the bot token (Bolt's injected client is gone — the receiver process exited) and calls `_process(...)`. The full agent run — streaming, tool calls, image generation — happens here, with Lambda's 300s budget all to itself. + ### Slack retry → DynamoDB conditional put dedup -`lambda_handler` short-circuits when `X-Slack-Retry-Num` header is present (returns 200 OK). Even without the retry header, the first line of `_process()` is `DedupStore.reserve(f"dedup:{client_msg_id}")` which does `put_item(ConditionExpression="attribute_not_exists(id)")`. Duplicate key raises `ConditionalCheckFailedException` → False → silent return. This is the only race-safe dedup (get-then-put has a window). TTL 1h via `expire_at`. +Three converging retry sources all funnel through one key: Slack's own 3-attempt retry schedule on the receiver, AWS Lambda's built-in 2x retry on async worker failure, and any accidental re-dispatch. `lambda_handler` short-circuits when `X-Slack-Retry-Num` header is present (returns 200 OK) so Slack retries never spawn a second worker. Inside the worker, the first line of `_process()` is `DedupStore.reserve(f"dedup:{client_msg_id}")` which does `put_item(ConditionExpression="attribute_not_exists(id)")`. Duplicate key raises `ConditionalCheckFailedException` → False → silent return. This is the only race-safe dedup (get-then-put has a window). TTL 1h via `expire_at`. Lambda async worker retries on the same `_worker` payload also hit the same dedup row, so a transient worker failure that Lambda retries can't produce a second reply. ### Single table, two key prefixes @@ -139,7 +146,8 @@ Neither extension requires editing the registry or the agent loop. ## Deployment `serverless.yml` provisions: -- Lambda: python3.12, x86_64, 5120MB, 300s timeout. The 300s value is well beyond the API Gateway REST integration timeout of 29s, but that's fine here — Lambda emits replies via the Slack Web API (`chat_postMessage`, `files_upload_v2`), not through the HTTP response that triggered the invocation, so the API Gateway 504 at 29s doesn't matter as long as dedup absorbs Slack's retry burst. Tool timeouts (e.g. `generate_image` 240s) are sized so that compose + upload + history-save fit in the remaining budget. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) +- Lambda: python3.12, x86_64, 5120MB, 300s timeout. The 300s value only applies to the worker path — the receiver path returns HTTP 200 within a few hundred ms via `_enqueue_worker`'s async self-invoke. The API Gateway REST integration timeout (29s) is only relevant to the receiver, which finishes long before that, so extending the worker budget to 300s (or further, up to Lambda's 900s cap) is safe. Tool timeouts (e.g. `generate_image` 240s) are sized so that compose + upload + history-save fit in the worker's remaining budget. (x86_64 matches the Ubuntu GitHub Actions runner so pip installs wheels — including native ones like `pydantic_core` — that run on the Lambda runtime. Switching to arm64 requires a Docker-based build path via serverless-python-requirements and is deferred.) +- IAM (runtime Lambda role) also grants `lambda:InvokeFunction` on this function's own ARN so `_enqueue_worker` can fire the async self-invoke. - DynamoDB: hash `id`, GSI `user-index` (user + expire_at, KEYS_ONLY), TTL `expire_at`. - IAM (runtime Lambda role): `dynamodb:GetItem/PutItem/Query` on table + GSI, `bedrock:InvokeModel*`/`Converse*`. @@ -156,7 +164,7 @@ Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:aws ## Testing -189 tests, 89% overall coverage. `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: +206 tests, 84% overall coverage (the drop vs. the 89% of `src/*`-only measurement is just `app.py` now being in scope: its `_process` path is exercised by live Slack traffic, not unit tests). `pytest.ini` pins `testpaths = tests`, `filterwarnings = ignore::DeprecationWarning`. Key approach: - Tests mirror source layout: `tests/llms/` for each `src/llms/*` submodule, `tests/tools/` for each `src/tools/*` submodule. Top-level `tests/test_agent.py`, `test_config.py`, `test_dedup.py`, `test_logging_utils.py`, `test_slack_helpers.py` cover the non-packaged modules. - Shared tool-test fixtures (`_ctx`, `_settings`, `_streamed_read`) live in `tests/tools/_helpers.py` — individual test files import from there instead of redefining them. @@ -169,7 +177,8 @@ Separate from the Lambda runtime role. `trust-policy.json` allows both `repo:aws Per-module coverage: -- `agent.py` 96%, `config.py` 98%, `dedup.py` 80%, `slack_helpers.py` 86%, `logging_utils.py` 68% +- `app.py` 35% (routing and worker fan-out covered by `tests/test_app.py`; `_process` is only hit in production) +- `agent.py` 96%, `config.py` 97%, `dedup.py` 80%, `slack_helpers.py` 86%, `logging_utils.py` 97% - `llms/`: `base.py` 70%, `openai_wire.py` 96%, `openai.py` 100%, `xai.py` 100%, `bedrock.py` 78%, `composite.py` 87%, `factory.py` 94% - `tools/`: `registry.py` 100%, `slack.py` 87%, `search.py` 93%, `web.py` 97%, `image.py` 100%, `time.py` 100% diff --git a/app.py b/app.py index 1557b44..b83750e 100644 --- a/app.py +++ b/app.py @@ -1,23 +1,41 @@ """AWS Lambda entrypoint for the Slack mention bot. -Flow per request: - 1. lambda_handler short-circuits Slack retries (X-Slack-Retry-Num header). - 2. Bolt dispatches event to app_mention / message handler. - 3. Handler acks immediately, then: - - Deduplicates on client_msg_id via DynamoDB conditional put. - - Checks channel allowlist + per-user throttle. - - Sets typing status + sends a placeholder message. - - Loads thread history from DynamoDB and runs the native-tool-calling agent. - - Streams the final answer into `chat_update` (first chunk) + `chat_postMessage` (rest). - - Persists updated conversation back to DynamoDB. +Two execution paths share this handler via a `_worker` flag in the event +payload: + + Receiver path (Slack → API Gateway → Lambda): + 1. lambda_handler short-circuits Slack retries (X-Slack-Retry-Num header). + 2. Bolt verifies the Slack signature and dispatches to app_mention / + message handlers. + 3. Handler acks, then fires a fire-and-forget `lambda:Invoke` against + this same function with `InvocationType=Event` and `_worker=True`. + 4. HTTP response returns to API Gateway within a few hundred ms, so + the 29s API Gateway integration timeout is irrelevant — even a + 10-minute agent run never blocks the HTTP response. + + Worker path (Lambda async self-invoke): + 1. `_worker=True` routes straight into _process_worker. + 2. Deduplicates on client_msg_id via DynamoDB conditional put. This + same dedup absorbs both Slack's own retry burst (on the receiver + side) AND Lambda async's built-in 2x retry on worker failure — all + paths converge on the same dedup:{msg_id} key. + 3. Checks channel allowlist + per-user throttle. + 4. Sets typing status + sends a placeholder message. + 5. Loads thread history from DynamoDB and runs the native-tool-calling + agent, then streams/posts the reply back via Slack Web API. + 6. Persists updated conversation back to DynamoDB. """ from __future__ import annotations +import json +import os import re import uuid +import boto3 from slack_bolt import App from slack_bolt.adapter.aws_lambda import SlackRequestHandler +from slack_sdk import WebClient from src.agent import SlackMentionAgent from src.config import Settings @@ -43,6 +61,7 @@ _dedup: DedupStore | None = None _conversations: ConversationStore | None = None _bolt_app: App | None = None +_lambda_client = None LABELS = { @@ -103,6 +122,67 @@ def _get_conversations() -> ConversationStore: return _conversations +def _get_lambda_client(): + global _lambda_client + if _lambda_client is None: + _lambda_client = boto3.client("lambda", region_name=settings.aws_region) + return _lambda_client + + +def _enqueue_worker(event: dict, is_dm: bool) -> None: + """Fire-and-forget async self-invoke. + + Keeps the receiver-path handler short so the HTTP response to API + Gateway returns within a few hundred ms, independent of how long the + agent takes. When `AWS_LAMBDA_FUNCTION_NAME` isn't set (local dev / + tests), falls through to inline execution so the code path is still + exercisable outside Lambda. + """ + function_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME") + if not function_name: + _process_worker({"slack_event": event, "is_dm": is_dm}) + return + payload = json.dumps( + {"_worker": True, "slack_event": event, "is_dm": is_dm}, ensure_ascii=False + ).encode("utf-8") + try: + _get_lambda_client().invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=payload, + ) + except Exception: + # If async invoke fails (IAM, throttling, network), we don't want + # to drop the user's message. Fall back to inline execution inside + # the receiver; worst case the receiver's Lambda timeout kicks in, + # but that's what we had before this refactor anyway. + logger.exception("async worker invoke failed, running inline") + _process_worker({"slack_event": event, "is_dm": is_dm}) + + +def _process_worker(payload: dict) -> None: + """Worker path: full agent run. + + Runs either as a real async Lambda invocation (payload carried across) + or inline during local/test execution. We build a fresh `WebClient` + from the bot token because the async invocation doesn't carry Bolt's + injected client — the receiver's Bolt context dies as soon as the + receiver returns HTTP 200. + """ + slack_event = payload.get("slack_event") or {} + is_dm = bool(payload.get("is_dm")) + channel = slack_event.get("channel") + client = WebClient(token=settings.slack_bot_token) + + def _say(text: str, thread_ts: str | None = None) -> None: + kwargs: dict = {"channel": channel, "text": text} + if thread_ts: + kwargs["thread_ts"] = thread_ts + client.chat_postMessage(**kwargs) + + _process(slack_event, client, _say, is_dm=is_dm) + + def _get_bolt_app() -> App: global _bolt_app if _bolt_app is not None: @@ -117,7 +197,7 @@ def _get_bolt_app() -> App: @app.event("app_mention") def _on_mention(event, client, say, ack): # noqa: ANN001 ack() - _process(event, client, say, is_dm=False) + _enqueue_worker(event, is_dm=False) @app.event("message") def _on_message(event, client, say, ack): # noqa: ANN001 @@ -126,7 +206,7 @@ def _on_message(event, client, say, ack): # noqa: ANN001 return if event.get("bot_id") or event.get("subtype"): return - _process(event, client, say, is_dm=True) + _enqueue_worker(event, is_dm=True) _bolt_app = app return _bolt_app @@ -320,7 +400,16 @@ def _on_step(step_num: int, phase: str, detail: dict) -> None: def lambda_handler(event, context): # noqa: ANN001 - # Short-circuit Slack retries without re-running the agent. + # Worker path: a Lambda async self-invoke with `_worker=True` skips + # Slack signature verification entirely. The only way to land here + # with this flag is via `_enqueue_worker`, which is only callable + # from inside a successfully verified receiver invocation. + if isinstance(event, dict) and event.get("_worker"): + _process_worker(event) + return {"statusCode": 200, "body": ""} + + # Receiver path: Slack HTTP event via API Gateway. + # Short-circuit Slack retries without re-dispatching the worker. headers = event.get("headers") or {} normalized = {k.lower(): v for k, v in headers.items()} if normalized.get("x-slack-retry-num"): diff --git a/serverless.yml b/serverless.yml index e5ac0b1..2282ff3 100644 --- a/serverless.yml +++ b/serverless.yml @@ -82,6 +82,14 @@ provider: - bedrock:Converse - bedrock:ConverseStream Resource: "*" + # Self-invoke (async) so the receiver path can fire-and-forget a + # worker invocation and return HTTP 200 to API Gateway / Slack + # within a few hundred ms. Scoped to this function's own ARN. + - Effect: Allow + Action: + - lambda:InvokeFunction + Resource: + - !Sub "arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${sls:stage}-mention" plugins: - serverless-python-requirements diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..decbc0a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,320 @@ +"""Tests for the receiver / worker split in app.lambda_handler. + +We only exercise the routing + enqueue logic here — `_process` itself is +covered transitively by tests/test_agent.py and friends. All external +dependencies (boto3, slack_sdk.WebClient, Bolt) are mocked so the tests +don't need real credentials. +""" +import json + +import pytest + + +@pytest.fixture +def app_module(): + """Import the app module fresh. + + `app.Settings.from_env()` reads env at import time but does not + validate Slack credentials, so this is safe without setting + SLACK_BOT_TOKEN / SLACK_SIGNING_SECRET. + """ + import app + + return app + + +# --------------------------------------------------------------------------- # +# lambda_handler routing +# --------------------------------------------------------------------------- # + + +def test_lambda_handler_routes_worker_flag_to_process_worker(app_module, monkeypatch): + """`event["_worker"] is True` must skip Slack / Bolt entirely and call the worker.""" + received = {} + + def fake_worker(payload): + received["payload"] = payload + + monkeypatch.setattr(app_module, "_process_worker", fake_worker) + + def boom_bolt(): + raise AssertionError("_get_bolt_app must not be called on the worker path") + + monkeypatch.setattr(app_module, "_get_bolt_app", boom_bolt) + + event = {"_worker": True, "slack_event": {"channel": "C1", "text": "hi"}, "is_dm": False} + result = app_module.lambda_handler(event, None) + + assert result == {"statusCode": 200, "body": ""} + assert received["payload"] is event + + +def test_lambda_handler_short_circuits_slack_retry_without_bolt(app_module, monkeypatch): + """Receiver path: an X-Slack-Retry-Num header means Slack is re-delivering. + We already dispatched the first try to a worker; swallow the retry.""" + + def boom_bolt(): + raise AssertionError("Bolt must not be invoked on a retried delivery") + + monkeypatch.setattr(app_module, "_get_bolt_app", boom_bolt) + + event = {"headers": {"X-Slack-Retry-Num": "1"}, "body": "..."} + assert app_module.lambda_handler(event, None) == {"statusCode": 200, "body": ""} + + +def test_lambda_handler_short_circuits_slack_retry_lowercase_header(app_module, monkeypatch): + """API Gateway may lowercase the header name. Our guard normalizes.""" + monkeypatch.setattr( + app_module, + "_get_bolt_app", + lambda: (_ for _ in ()).throw(AssertionError("should not hit Bolt")), + ) + event = {"headers": {"x-slack-retry-num": "3"}} + assert app_module.lambda_handler(event, None) == {"statusCode": 200, "body": ""} + + +def test_lambda_handler_delegates_normal_request_to_bolt(app_module, monkeypatch): + """A normal Slack HTTP event (no retry header, no _worker flag) must + reach the Bolt SlackRequestHandler.""" + calls = [] + + class FakeHandler: + def __init__(self, bolt_app): + calls.append(("init", bolt_app)) + + def handle(self, event, context): + calls.append(("handle", event, context)) + return {"statusCode": 200, "body": "bolt"} + + monkeypatch.setattr(app_module, "SlackRequestHandler", FakeHandler) + monkeypatch.setattr(app_module, "_get_bolt_app", lambda: "fake-bolt-app") + + event = {"headers": {"Content-Type": "application/json"}, "body": "{}"} + result = app_module.lambda_handler(event, "ctx") + + assert result == {"statusCode": 200, "body": "bolt"} + assert calls[0] == ("init", "fake-bolt-app") + assert calls[1] == ("handle", event, "ctx") + + +def test_lambda_handler_worker_flag_false_takes_receiver_path(app_module, monkeypatch): + """`_worker=False` should fall through to the receiver path, not be + treated as a worker marker.""" + + def boom_worker(payload): + raise AssertionError("_process_worker must not run when _worker is falsy") + + monkeypatch.setattr(app_module, "_process_worker", boom_worker) + + class FakeHandler: + def __init__(self, bolt_app): + pass + + def handle(self, event, context): + return {"statusCode": 202} + + monkeypatch.setattr(app_module, "SlackRequestHandler", FakeHandler) + monkeypatch.setattr(app_module, "_get_bolt_app", lambda: object()) + + assert app_module.lambda_handler({"_worker": False, "headers": {}}, None) == {"statusCode": 202} + + +# --------------------------------------------------------------------------- # +# _enqueue_worker — receiver → worker bridge +# --------------------------------------------------------------------------- # + + +def test_enqueue_worker_runs_inline_when_not_in_lambda(app_module, monkeypatch): + """Without AWS_LAMBDA_FUNCTION_NAME (local dev / tests), the + receiver must execute the worker path inline rather than trying to + issue a boto3.invoke that would fail against real AWS.""" + monkeypatch.delenv("AWS_LAMBDA_FUNCTION_NAME", raising=False) + + def boom_client(): + raise AssertionError("boto3 must not be touched off-Lambda") + + monkeypatch.setattr(app_module, "_get_lambda_client", boom_client) + + captured = [] + monkeypatch.setattr(app_module, "_process_worker", lambda payload: captured.append(payload)) + + event = {"channel": "C1", "text": "hello"} + app_module._enqueue_worker(event, is_dm=False) + + assert captured == [{"slack_event": event, "is_dm": False}] + + +def test_enqueue_worker_fires_async_invoke_in_lambda(app_module, monkeypatch): + """When AWS_LAMBDA_FUNCTION_NAME is set, issue an async Lambda invoke + with the correct payload shape — and NOT run the worker inline.""" + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "gurumi-mention") + + invocations = [] + + class FakeLambdaClient: + def invoke(self, **kwargs): + invocations.append(kwargs) + return {"StatusCode": 202} + + monkeypatch.setattr(app_module, "_get_lambda_client", lambda: FakeLambdaClient()) + + def boom_inline(payload): + raise AssertionError("inline worker must not run when invoke succeeds") + + monkeypatch.setattr(app_module, "_process_worker", boom_inline) + + event = {"channel": "D1", "text": "안녕", "user": "U1"} + app_module._enqueue_worker(event, is_dm=True) + + assert len(invocations) == 1 + call = invocations[0] + assert call["FunctionName"] == "gurumi-mention" + assert call["InvocationType"] == "Event" + payload = json.loads(call["Payload"].decode("utf-8")) + assert payload == {"_worker": True, "slack_event": event, "is_dm": True} + + +def test_enqueue_worker_falls_back_to_inline_on_invoke_failure(app_module, monkeypatch): + """If boto3.invoke raises (IAM denied / throttle / network), fall back + to inline execution so the user's message isn't silently dropped.""" + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "gurumi-mention") + + class BrokenClient: + def invoke(self, **_kwargs): + raise RuntimeError("network unreachable") + + monkeypatch.setattr(app_module, "_get_lambda_client", lambda: BrokenClient()) + + captured = [] + monkeypatch.setattr(app_module, "_process_worker", lambda payload: captured.append(payload)) + + event = {"channel": "C1", "text": "hi"} + app_module._enqueue_worker(event, is_dm=False) + + assert captured == [{"slack_event": event, "is_dm": False}] + + +def test_enqueue_worker_payload_preserves_non_ascii(app_module, monkeypatch): + """Korean / emoji in the Slack event must survive the JSON round-trip.""" + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "gurumi-mention") + + invocations = [] + + class FakeClient: + def invoke(self, **kwargs): + invocations.append(kwargs) + + monkeypatch.setattr(app_module, "_get_lambda_client", lambda: FakeClient()) + monkeypatch.setattr(app_module, "_process_worker", lambda _p: None) + + event = {"text": "이미지 그려줘 🎨", "channel": "C1"} + app_module._enqueue_worker(event, is_dm=False) + + payload = json.loads(invocations[0]["Payload"].decode("utf-8")) + assert payload["slack_event"]["text"] == "이미지 그려줘 🎨" + + +# --------------------------------------------------------------------------- # +# _process_worker — the async Lambda entrypoint +# --------------------------------------------------------------------------- # + + +def test_process_worker_builds_webclient_from_bot_token(app_module, monkeypatch): + """Bolt's injected WebClient is gone by the time the worker invocation + runs — it lived in the receiver process. The worker must mint a fresh + one from the bot token on `settings`. + + `Settings` is a frozen dataclass, so we replace the whole module-level + `settings` with a dataclasses.replace() copy that carries the token + we want to observe. + """ + import dataclasses + + override = dataclasses.replace(app_module.settings, slack_bot_token="xoxb-test-token") + monkeypatch.setattr(app_module, "settings", override) + + created = [] + + class FakeWeb: + def __init__(self, token): + created.append(token) + self.token = token + + monkeypatch.setattr(app_module, "WebClient", FakeWeb) + + captured = {} + + def fake_process(event, client, say, is_dm): + captured["event"] = event + captured["client"] = client + captured["say"] = say + captured["is_dm"] = is_dm + + monkeypatch.setattr(app_module, "_process", fake_process) + + payload = {"slack_event": {"channel": "C1", "text": "hi"}, "is_dm": True} + app_module._process_worker(payload) + + assert created == ["xoxb-test-token"] + assert captured["event"] == {"channel": "C1", "text": "hi"} + assert captured["is_dm"] is True + assert isinstance(captured["client"], FakeWeb) + assert callable(captured["say"]) + + +def test_process_worker_say_callable_posts_to_event_channel(app_module, monkeypatch): + """The `say` closure passed to _process must post to the channel the + original Slack event came from — not some default — and must forward + thread_ts when provided.""" + posts = [] + + class FakeWeb: + def __init__(self, token): + pass + + def chat_postMessage(self, **kwargs): + posts.append(kwargs) + + monkeypatch.setattr(app_module, "WebClient", FakeWeb) + + captured_say = {} + + def fake_process(event, client, say, is_dm): + captured_say["fn"] = say + + monkeypatch.setattr(app_module, "_process", fake_process) + + payload = {"slack_event": {"channel": "C-origin", "text": "x"}, "is_dm": False} + app_module._process_worker(payload) + + say = captured_say["fn"] + say("hello world") + say("threaded reply", thread_ts="1700000000.000100") + + assert posts == [ + {"channel": "C-origin", "text": "hello world"}, + {"channel": "C-origin", "text": "threaded reply", "thread_ts": "1700000000.000100"}, + ] + + +def test_process_worker_tolerates_missing_fields(app_module, monkeypatch): + """Defensive: a malformed payload (no slack_event, no is_dm) must + still reach _process with sane defaults rather than crash.""" + + class FakeWeb: + def __init__(self, token): + pass + + monkeypatch.setattr(app_module, "WebClient", FakeWeb) + + seen = {} + + def fake_process(event, client, say, is_dm): + seen["event"] = event + seen["is_dm"] = is_dm + + monkeypatch.setattr(app_module, "_process", fake_process) + + app_module._process_worker({}) + + assert seen == {"event": {}, "is_dm": False} From 5c7b3043a6e19556fcc11abe069ab0f5a45309bb Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 28 Apr 2026 16:28:55 +0900 Subject: [PATCH 56/65] feat: configure Bedrock client with explicit timeout and retry settings --- src/llms/bedrock.py | 20 +++++++++++++++++++- tests/llms/test_bedrock.py | 27 ++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/llms/bedrock.py b/src/llms/bedrock.py index 2af5de8..2d80b83 100644 --- a/src/llms/bedrock.py +++ b/src/llms/bedrock.py @@ -8,6 +8,7 @@ from typing import Any, Callable, Literal import boto3 +from botocore.config import Config from src.llms.base import LLMResult, ToolCall, ToolSpec, _with_retry @@ -44,7 +45,24 @@ def __init__(self, model: str, image_model: str, region: str): def _get_client(self): if self._client is None: - self._client = boto3.client("bedrock-runtime", region_name=self.region) + # Claude Sonnet/Opus generations regularly run 60-120s server-side. + # botocore's default read_timeout=60s + legacy retry mode (5 attempts) + # silently re-invokes the model up to 5x and exhausts Lambda's 300s + # budget before any exception surfaces (see: 5x AWS/Bedrock + # Invocations metric for a single user turn). Keep the read_timeout + # close to the worker Lambda's own timeout, drop retries to standard + # mode with 2 attempts so a transient throttle still recovers + # without amplifying read-timeout stalls. + cfg = Config( + connect_timeout=10, + read_timeout=290, + retries={"max_attempts": 2, "mode": "standard"}, + ) + self._client = boto3.client( + "bedrock-runtime", + region_name=self.region, + config=cfg, + ) return self._client @property diff --git a/tests/llms/test_bedrock.py b/tests/llms/test_bedrock.py index 1f21e22..803d2ff 100644 --- a/tests/llms/test_bedrock.py +++ b/tests/llms/test_bedrock.py @@ -3,7 +3,7 @@ import base64 import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -312,3 +312,28 @@ def test_bedrock_generate_image_stability_returns_bytes(): {"artifacts": [{"base64": base64.b64encode(b"xyz").decode()}]} ) assert provider.generate_image("cat") == b"xyz" + + +def test_bedrock_client_uses_explicit_timeout_and_retry_config(): + """Regression: default botocore read_timeout=60s + legacy retry=5 turns a + single 80-90s Sonnet generation into 5 silent re-invocations that exhaust + Lambda's 300s budget before any exception surfaces. The bedrock-runtime + client must be built with an explicit Config that aligns read_timeout with + the worker Lambda timeout and caps retries at 2 in standard mode.""" + provider = BedrockProvider( + model="us.anthropic.claude-sonnet-4-5-20250929-v1:0", + image_model="amazon.nova-canvas-v1:0", + region="us-east-1", + ) + with patch("src.llms.bedrock.boto3.client") as mock_boto3_client: + mock_boto3_client.return_value = MagicMock() + provider._get_client() + + mock_boto3_client.assert_called_once() + kwargs = mock_boto3_client.call_args.kwargs + assert kwargs["region_name"] == "us-east-1" + cfg = kwargs["config"] + assert cfg.connect_timeout == 10 + assert cfg.read_timeout == 290 + assert cfg.retries == {"max_attempts": 2, "mode": "standard"} + From 6a591f013e205bc247e077ba7f9e90c2d0506e36 Mon Sep 17 00:00:00 2001 From: nalbam Date: Tue, 28 Apr 2026 16:55:45 +0900 Subject: [PATCH 57/65] feat: add error handling for Slack API message length limits in StreamingMessage --- src/slack_helpers.py | 64 +++++++++++++++++++++++++++++++++++++ tests/test_slack_helpers.py | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 1cf34d0..2835373 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -19,6 +19,22 @@ SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") +def _slack_error_code(exc: SlackApiError) -> str: + """Extract the `error` field from a SlackApiError's response payload. + + SlackApiError.response is normally a SlackResponse mapping; access via + `.get("error")` works on both dict-like and SlackResponse objects. + Falls back to empty string if anything is unexpected. + """ + response = getattr(exc, "response", None) + if response is None: + return "" + try: + return response.get("error", "") or "" + except (AttributeError, TypeError): + return "" + + class MessageFormatter: """Split a long message into Slack-safe chunks. @@ -245,6 +261,18 @@ def _flush(self) -> None: self.client.chat_update(channel=self.channel, ts=self.ts, text=display) self._consecutive_update_failures = 0 except SlackApiError as exc: + # msg_too_long is an explicit "this payload exceeds chat.update's + # rendered limit" signal — Slack's mrkdwn → section-block coercion + # caps a single block at ~3000 chars and fails well before the + # documented 4000-char text limit on multibyte/markdown content. + # Retrying the same buffer against a fresh ts via _roll_to_new_message + # just produces the same failure on the new placeholder, leaving a + # trail of empty :loading: messages in the thread. Spill the buffer + # via chat.postMessage (40k-char limit, no rolling-update history) + # and drop the current placeholder so the next delta starts fresh. + if _slack_error_code(exc) == "msg_too_long": + self._spill_buffer_via_post_message(text) + return self._consecutive_update_failures += 1 logger.warning( "chat_update during stream failed (%d consecutive): %s", @@ -259,6 +287,35 @@ def _flush(self) -> None: self._consecutive_update_failures = 0 self._roll_to_new_message(preserve_buffer=True) + def _spill_buffer_via_post_message(self, text: str) -> None: + """Recover from chat.update msg_too_long by posting the buffered text + as fresh thread messages and dropping the current placeholder ts. + + chat.postMessage's 40k-char limit is much more permissive than + chat.update's effective ~3k-char limit on multibyte/markdown content, + so MessageFormatter.split_message at self.max_len reliably fits. + Deleting the (still :loading:) placeholder keeps the thread free of + the empty placeholder + spilled-answer "double output" pattern. + Setting `self.ts = None` lets the next delta lazy-start a fresh + placeholder via the same deferred path used at first delta. + """ + chunks = MessageFormatter.split_message(text, max_len=self.max_len) + for chunk in chunks: + try: + self.client.chat_postMessage( + channel=self.channel, thread_ts=self.thread_ts, text=chunk, + ) + except SlackApiError as exc: + logger.warning("spill chat_postMessage failed: %s", exc) + if self.ts: + try: + self.client.chat_delete(channel=self.channel, ts=self.ts) + except SlackApiError as exc: + logger.debug("placeholder chat_delete after spill failed: %s", exc) + self._buffer = "" + self._consecutive_update_failures = 0 + self.ts = None + def _roll_to_new_message(self, preserve_buffer: bool = False) -> None: """Open a fresh placeholder message and reset the buffer. Used when the fallback rolling update would overflow the per-message limit, @@ -327,6 +384,13 @@ def stop(self, final_text: str) -> None: self.client.chat_update(channel=self.channel, ts=self.ts, text=first) except SlackApiError as exc: logger.warning("final chat_update failed (len=%d): %s", len(first), exc) + # Drop the still-:loading: placeholder before posting the answer + # via chat.postMessage; otherwise the user sees the placeholder + # and the spilled answer side-by-side ("double output"). + try: + self.client.chat_delete(channel=self.channel, ts=self.ts) + except SlackApiError as exc2: + logger.debug("placeholder chat_delete after final failure: %s", exc2) # Fallback to postMessage so at least the text lands somewhere. try: self.client.chat_postMessage( diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index b0aa203..ba7df8c 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -388,3 +388,63 @@ def test_streaming_message_stop_splits_long_final(): assert len(follow_calls) >= 1 +def test_streaming_message_flush_msg_too_long_spills_via_postmessage_and_drops_placeholder(): + """Regression: chat.update's effective limit on multibyte/markdown content + is well under its documented 4000-char cap (mrkdwn → section block coerces + to ~3000). Retrying the same buffer against a fresh ts via roll-with- + preserve-buffer produces the same failure on every new placeholder, so + the user ends up seeing N empty :loading: messages plus the spilled + answer ("double output"). msg_too_long must spill via chat.postMessage + and chat.delete the placeholder.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "placeholder-ts"} + client.chat_update.side_effect = SlackApiError( + "too long", {"error": "msg_too_long"} + ) + sm = StreamingMessage( + client=client, channel="C1", thread_ts="ts1", min_interval=0.0, max_len=10_000 + ) + sm.start() + sm.append("a" * 1500) + + # Spill path: at least one chat.postMessage with the buffer body went out, + # the placeholder was deleted, and ts is reset so the next delta lazily + # opens a fresh placeholder via the deferred-start path. + spill_calls = [ + c + for c in client.chat_postMessage.call_args_list + if c.kwargs.get("thread_ts") == "ts1" and c.kwargs.get("text", "").startswith("a") + ] + assert len(spill_calls) >= 1 + client.chat_delete.assert_called_with(channel="C1", ts="placeholder-ts") + assert sm.ts is None + assert sm._buffer == "" + assert sm._consecutive_update_failures == 0 + + +def test_streaming_message_stop_msg_too_long_deletes_placeholder(): + """If the final chat.update fails (e.g. msg_too_long on a multi-paragraph + answer), the placeholder must be deleted before the fallback chat.postMessage + so the user doesn't see :loading: alongside the answer.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "placeholder-ts"} + client.chat_update.side_effect = SlackApiError( + "too long", {"error": "msg_too_long"} + ) + sm = StreamingMessage(client=client, channel="C1", thread_ts="ts1", max_len=10_000) + sm.start() + sm.stop("final answer") + + client.chat_delete.assert_called_with(channel="C1", ts="placeholder-ts") + # Fallback postMessage carries the answer; the start() call also posted a + # placeholder, so we filter on the actual answer body. + answer_posts = [ + c + for c in client.chat_postMessage.call_args_list + if c.kwargs.get("text") == "final answer" + ] + assert len(answer_posts) == 1 + + From dc48b06501f3cf9a47f02f4235438804d59ee838 Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 10:44:12 +0900 Subject: [PATCH 58/65] feat: update default allowed channel message for user guidance --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index 9cf657d..e847c26 100644 --- a/src/config.py +++ b/src/config.py @@ -140,7 +140,7 @@ def from_env(cls) -> "Settings": dynamodb_table_name=os.getenv("DYNAMODB_TABLE_NAME", "lambda-gurumi-bot-dev").strip(), aws_region=os.getenv("AWS_REGION", "us-east-1").strip(), allowed_channel_ids=_list_env("ALLOWED_CHANNEL_IDS"), - allowed_channel_message=os.getenv("ALLOWED_CHANNEL_MESSAGE", "").strip(), + allowed_channel_message=os.getenv("ALLOWED_CHANNEL_MESSAGE", "구루미에게 질문은 {} 채널을 이용해 주세요~").strip(), max_len_slack=_int_env("MAX_LEN_SLACK", 2000, minimum=500), max_throttle_count=_int_env("MAX_THROTTLE_COUNT", 100, minimum=1), max_history_chars=_int_env("MAX_HISTORY_CHARS", 4000, minimum=500), From a1aada3a8ecb959083f026f918dc9e666c0c94b5 Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 10:45:35 +0900 Subject: [PATCH 59/65] feat: implement channel message substitution for blocked channels --- app.py | 2 + tests/test_app.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/app.py b/app.py index b83750e..fe0ff6c 100644 --- a/app.py +++ b/app.py @@ -246,6 +246,8 @@ def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 # workspace install permission already gates who can open the DM. if not is_dm and not channel_allowed(channel, settings.allowed_channel_ids): msg = settings.allowed_channel_message or "" + if msg and "{}" in msg and settings.allowed_channel_ids: + msg = msg.replace("{}", f"<#{settings.allowed_channel_ids[0]}>") if msg: say(text=msg, thread_ts=thread_ts) log_event(logger, "channel.blocked", channel=channel) diff --git a/tests/test_app.py b/tests/test_app.py index decbc0a..b9af43e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -318,3 +318,111 @@ def fake_process(event, client, say, is_dm): app_module._process_worker({}) assert seen == {"event": {}, "is_dm": False} + + +# --------------------------------------------------------------------------- # +# Channel allowlist — block reply with first-channel substitution +# --------------------------------------------------------------------------- # + + +class _FakeDedup: + """Minimal DedupStore stand-in: reserve always succeeds, no throttle.""" + + def reserve(self, key, user="system"): + return True + + def count_user_active(self, user): + return 0 + + +def test_process_blocked_channel_substitutes_first_allowed_channel(app_module, monkeypatch): + """비허용 채널 응답의 `{}` 는 ALLOWED_CHANNEL_IDS 의 첫 번째 채널로 치환되며, + Slack 채널 멘션 형식(`<#ID>`)으로 감싸 클릭 가능한 링크로 렌더되어야 한다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C04PPA399CP", "C08A9550X"], + allowed_channel_message="구루미에게 질문은 {} 채널을 이용해 주세요~", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + + def fake_say(text, thread_ts=None): + posts.append({"text": text, "thread_ts": thread_ts}) + + event = { + "channel": "C-BLOCKED", + "ts": "1700000000.000100", + "text": "hi", + "user": "U1", + "client_msg_id": "msg-block-1", + } + app_module._process(event, client=object(), say=fake_say, is_dm=False) + + assert posts == [ + { + "text": "구루미에게 질문은 <#C04PPA399CP> 채널을 이용해 주세요~", + "thread_ts": "1700000000.000100", + } + ] + + +def test_process_blocked_channel_message_without_placeholder_unchanged(app_module, monkeypatch): + """`{}` 가 없는 메시지는 가공 없이 그대로 전송되어야 한다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C04PPA399CP"], + allowed_channel_message="허용되지 않은 채널입니다.", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-X", + "ts": "1.1", + "text": "hi", + "user": "U1", + "client_msg_id": "msg-block-2", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [{"text": "허용되지 않은 채널입니다.", "thread_ts": "1.1"}] + + +def test_process_blocked_channel_no_message_when_unset(app_module, monkeypatch): + """ALLOWED_CHANNEL_MESSAGE 가 비어 있으면 차단된 채널에서 아무 응답도 가지 않는다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C04PPA399CP"], + allowed_channel_message="", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-X", + "ts": "1.1", + "text": "hi", + "user": "U1", + "client_msg_id": "msg-block-3", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [] From 075027b993cdf7e01cab3bebb112d59d19314acc Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 10:51:05 +0900 Subject: [PATCH 60/65] feat: add user allowlist functionality with customizable messages for blocked users --- .env.example | 4 +- .github/workflows/push.yml | 2 + README.md | 6 +- app.py | 15 +++++ serverless.yml | 2 + src/config.py | 4 ++ tests/test_app.py | 127 +++++++++++++++++++++++++++++++++++++ tests/test_config.py | 14 +++- 8 files changed, 170 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 2e9264d..5695673 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,9 @@ PERSONA_MESSAGE="" # optional answer style / tone (e.g. "자연스 # --- Access control / throttle --- ALLOWED_CHANNEL_IDS="" # comma-separated channel IDs, empty = allow all -ALLOWED_CHANNEL_MESSAGE="" # reply sent when channel not allowed +ALLOWED_CHANNEL_MESSAGE="" # reply sent when channel not allowed; "{}" → first allowed channel +ALLOWED_USER_IDS="" # comma-separated user IDs, empty = allow all (applies to DMs too) +ALLOWED_USER_MESSAGE="" # reply sent when user not allowed; "{}" → first allowed user MAX_THROTTLE_COUNT=100 # per-user active request cap # --- Storage (DynamoDB — created by serverless deploy) --- diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index fd9947d..487d93e 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -74,6 +74,8 @@ jobs: # --- vars: Access control / throttle --- ALLOWED_CHANNEL_IDS: ${{ vars.ALLOWED_CHANNEL_IDS }} ALLOWED_CHANNEL_MESSAGE: ${{ vars.ALLOWED_CHANNEL_MESSAGE }} + ALLOWED_USER_IDS: ${{ vars.ALLOWED_USER_IDS }} + ALLOWED_USER_MESSAGE: ${{ vars.ALLOWED_USER_MESSAGE }} MAX_THROTTLE_COUNT: ${{ vars.MAX_THROTTLE_COUNT }} # --- vars: Storage --- MAX_HISTORY_CHARS: ${{ vars.MAX_HISTORY_CHARS }} diff --git a/README.md b/README.md index 6b49c28..0421e47 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ Slack 멘션·DM 을 AWS Lambda 에서 처리하고, OpenAI · AWS Bedrock · xA | `DYNAMODB_TABLE_NAME` | | `lambda-gurumi-bot-dev` | dedup / 대화 저장 테이블 | | `AWS_REGION` | | `us-east-1` | AWS 리전 | | `ALLOWED_CHANNEL_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 채널 허용. **DM(`message.im`) 은 허용 리스트 대상이 아님** — allowlist 를 설정해도 DM 경로는 항상 허용됨 | -| `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 (DM 에는 적용되지 않음) | +| `ALLOWED_CHANNEL_MESSAGE` | | — | 비허용 채널 응답 메시지 (DM 에는 적용되지 않음). `{}` 가 있으면 `ALLOWED_CHANNEL_IDS` 의 첫 채널을 `<#ID>` 멘션 형태로 치환 | +| `ALLOWED_USER_IDS` | | (empty) | 콤마 구분. 비어있으면 모든 유저 허용. **채널·DM 모든 경로에 적용** — DM 도 차단 | +| `ALLOWED_USER_MESSAGE` | | — | 비허용 유저 응답 메시지. `{}` 가 있으면 `ALLOWED_USER_IDS` 의 첫 유저를 `<@ID>` 멘션 형태로 치환 | | `MAX_LEN_SLACK` | | `3000` | 메시지 분할 기준 (≥500). `.env.example` · `serverless.yml` 기본 `3000`, 미지정 시 `config.py` 폴백 `2000`. | | `MAX_OUTPUT_TOKENS` | | `4096` | LLM hop 당 출력 토큰 상한 (≥256) | | `MAX_THROTTLE_COUNT` | | `100` | 유저별 동시 요청 상한 | @@ -128,7 +130,7 @@ aws iam attach-role-policy --role-name "${NAME}" --policy-arn "arn:aws:iam::${AC ### 2. GitHub 저장소 설정 - **Secrets**: `AWS_ACCOUNT_ID`, `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `OPENAI_API_KEY`, `XAI_API_KEY`(xAI 사용 시), `TAVILY_API_KEY`(선택) -- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `SYSTEM_MESSAGE`, `PERSONA_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES`, `MAX_WEB_CHARS`, `MAX_WEB_BYTES`, `MAX_WEB_LINKS`, `JINA_READER_BASE` +- **Variables**: `LLM_PROVIDER`, `LLM_MODEL`, `IMAGE_PROVIDER`, `IMAGE_MODEL`, `RESPONSE_LANGUAGE`, `ALLOWED_CHANNEL_IDS`, `ALLOWED_CHANNEL_MESSAGE`, `ALLOWED_USER_IDS`, `ALLOWED_USER_MESSAGE`, `SYSTEM_MESSAGE`, `PERSONA_MESSAGE`, `BOT_CURSOR`, `MAX_LEN_SLACK`, `MAX_OUTPUT_TOKENS`, `MAX_THROTTLE_COUNT`, `MAX_HISTORY_CHARS`, `AGENT_MAX_STEPS`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`, `MAX_DOC_CHARS`, `MAX_DOC_PAGES`, `MAX_DOC_BYTES`, `MAX_WEB_CHARS`, `MAX_WEB_BYTES`, `MAX_WEB_LINKS`, `JINA_READER_BASE` ### 3. 배포 diff --git a/app.py b/app.py index fe0ff6c..b6a41b7 100644 --- a/app.py +++ b/app.py @@ -253,6 +253,21 @@ def _process(event: dict, client, say, is_dm: bool) -> None: # noqa: ANN001 log_event(logger, "channel.blocked", channel=channel) return + # User allowlist applies to channels AND DMs. Unlike the channel allowlist + # (which exempts DMs because DM channel IDs are D-prefixed and wouldn't + # be enrolled), restricting *who* can talk to the bot is meaningful in + # both directions — arguably more so in DMs, where there's no channel- + # level gate at all. Operator opts in via ALLOWED_USER_IDS; empty list + # means everyone is allowed. + if settings.allowed_user_ids and user not in settings.allowed_user_ids: + msg = settings.allowed_user_message or "" + if msg and "{}" in msg: + msg = msg.replace("{}", f"<@{settings.allowed_user_ids[0]}>") + if msg: + say(text=msg, thread_ts=thread_ts) + log_event(logger, "user.blocked", user=user, channel=channel) + return + try: active = dedup.count_user_active(user) except Exception as exc: # noqa: BLE001 diff --git a/serverless.yml b/serverless.yml index 2282ff3..c0b14eb 100644 --- a/serverless.yml +++ b/serverless.yml @@ -49,6 +49,8 @@ provider: # --- vars: Access control / throttle --- ALLOWED_CHANNEL_IDS: ${env:ALLOWED_CHANNEL_IDS, ''} ALLOWED_CHANNEL_MESSAGE: ${env:ALLOWED_CHANNEL_MESSAGE, ''} + ALLOWED_USER_IDS: ${env:ALLOWED_USER_IDS, ''} + ALLOWED_USER_MESSAGE: ${env:ALLOWED_USER_MESSAGE, ''} MAX_THROTTLE_COUNT: ${env:MAX_THROTTLE_COUNT, '100'} # --- vars: Storage --- MAX_HISTORY_CHARS: ${env:MAX_HISTORY_CHARS, '4000'} diff --git a/src/config.py b/src/config.py index e847c26..633452b 100644 --- a/src/config.py +++ b/src/config.py @@ -96,6 +96,8 @@ class Settings: aws_region: str allowed_channel_ids: list[str] = field(default_factory=list) allowed_channel_message: str = "" + allowed_user_ids: list[str] = field(default_factory=list) + allowed_user_message: str = "" max_len_slack: int = 3000 max_throttle_count: int = 100 max_history_chars: int = 4000 @@ -141,6 +143,8 @@ def from_env(cls) -> "Settings": aws_region=os.getenv("AWS_REGION", "us-east-1").strip(), allowed_channel_ids=_list_env("ALLOWED_CHANNEL_IDS"), allowed_channel_message=os.getenv("ALLOWED_CHANNEL_MESSAGE", "구루미에게 질문은 {} 채널을 이용해 주세요~").strip(), + allowed_user_ids=_list_env("ALLOWED_USER_IDS"), + allowed_user_message=os.getenv("ALLOWED_USER_MESSAGE", "허용된 유저만 응답합니다.").strip(), max_len_slack=_int_env("MAX_LEN_SLACK", 2000, minimum=500), max_throttle_count=_int_env("MAX_THROTTLE_COUNT", 100, minimum=1), max_history_chars=_int_env("MAX_HISTORY_CHARS", 4000, minimum=500), diff --git a/tests/test_app.py b/tests/test_app.py index b9af43e..d2d7eb3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -426,3 +426,130 @@ def test_process_blocked_channel_no_message_when_unset(app_module, monkeypatch): ) assert posts == [] + + +# --------------------------------------------------------------------------- # +# User allowlist — block reply with first-user substitution (channel + DM) +# --------------------------------------------------------------------------- # + + +def test_process_blocked_user_substitutes_first_allowed_user(app_module, monkeypatch): + """비허용 유저 응답의 `{}` 는 ALLOWED_USER_IDS 의 첫 번째 유저를 + Slack 멘션 형식(`<@ID>`)으로 치환해야 한다. 채널 검사를 통과한 뒤에도 유저로 + 차단되는 케이스.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=[], # 채널 검사 통과 + allowed_user_ids=["U-ADMIN", "U-OPS"], + allowed_user_message="이 봇은 {} 만 답변합니다.", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-OK", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-user-1", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [{"text": "이 봇은 <@U-ADMIN> 만 답변합니다.", "thread_ts": "1.1"}] + + +def test_process_blocked_user_applies_in_dm(app_module, monkeypatch): + """유저 화이트리스트는 DM 경로에도 적용되어야 한다 — 채널 화이트리스트와의 + 핵심 차이. is_dm=True 라도 비허용 유저는 차단 메시지를 받는다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_user_ids=["U-ADMIN"], + allowed_user_message="DM 도 차단합니다.", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "D-DM", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-user-2", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=True, + ) + + assert posts == [{"text": "DM 도 차단합니다.", "thread_ts": "1.1"}] + + +def test_process_blocked_user_no_message_when_unset(app_module, monkeypatch): + """ALLOWED_USER_MESSAGE 가 비어 있으면 차단된 유저에게 응답이 가지 않는다.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_user_ids=["U-ADMIN"], + allowed_user_message="", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-OK", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-user-3", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [] + + +def test_process_blocked_channel_short_circuits_before_user_check(app_module, monkeypatch): + """채널·유저 둘 다 차단인 경우 채널 메시지 한 번만 전송 — 유저 검사로 진행 안 됨.""" + import dataclasses + + override = dataclasses.replace( + app_module.settings, + allowed_channel_ids=["C-OK"], + allowed_channel_message="채널 차단", + allowed_user_ids=["U-ADMIN"], + allowed_user_message="유저 차단", + ) + monkeypatch.setattr(app_module, "settings", override) + monkeypatch.setattr(app_module, "_get_dedup", lambda: _FakeDedup()) + + posts = [] + app_module._process( + { + "channel": "C-BAD", + "ts": "1.1", + "text": "hi", + "user": "U-RANDOM", + "client_msg_id": "msg-both-1", + }, + client=object(), + say=lambda text, thread_ts=None: posts.append({"text": text, "thread_ts": thread_ts}), + is_dm=False, + ) + + assert posts == [{"text": "채널 차단", "thread_ts": "1.1"}] diff --git a/tests/test_config.py b/tests/test_config.py index 8d0d9d1..d59e7ba 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,7 +18,8 @@ def _clear_env(monkeypatch): "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "LLM_PROVIDER", "LLM_MODEL", "IMAGE_PROVIDER", "IMAGE_MODEL", "OPENAI_API_KEY", "RESPONSE_LANGUAGE", "AGENT_MAX_STEPS", "DYNAMODB_TABLE_NAME", "AWS_REGION", "ALLOWED_CHANNEL_IDS", - "ALLOWED_CHANNEL_MESSAGE", "MAX_LEN_SLACK", "MAX_THROTTLE_COUNT", + "ALLOWED_CHANNEL_MESSAGE", "ALLOWED_USER_IDS", "ALLOWED_USER_MESSAGE", + "MAX_LEN_SLACK", "MAX_THROTTLE_COUNT", "MAX_HISTORY_CHARS", "BOT_CURSOR", "SYSTEM_MESSAGE", "PERSONA_MESSAGE", "TAVILY_API_KEY", "XAI_API_KEY", "LOG_LEVEL", "DEFAULT_TIMEZONE", "MAX_DOC_CHARS", "MAX_DOC_PAGES", "MAX_DOC_BYTES", "MAX_WEB_CHARS", "MAX_WEB_BYTES", "MAX_WEB_LINKS", "JINA_READER_BASE", @@ -37,6 +38,8 @@ def test_defaults(monkeypatch, reload_config): assert s.agent_max_steps == 3 assert s.max_len_slack == 2000 assert s.allowed_channel_ids == [] + assert s.allowed_user_ids == [] + assert s.allowed_user_message == "허용된 유저만 응답합니다." assert s.tavily_api_key is None @@ -77,6 +80,15 @@ def test_list_env_none_sentinel(monkeypatch, reload_config): assert s.allowed_channel_ids == [] +def test_allowed_user_ids_parsed_from_env(monkeypatch, reload_config): + _clear_env(monkeypatch) + monkeypatch.setenv("ALLOWED_USER_IDS", "U1,U2, U3 ") + monkeypatch.setenv("ALLOWED_USER_MESSAGE", "허용된 유저만 응답합니다.") + s = reload_config() + assert s.allowed_user_ids == ["U1", "U2", "U3"] + assert s.allowed_user_message == "허용된 유저만 응답합니다." + + def test_require_slack_credentials_raises_when_missing(monkeypatch, reload_config): _clear_env(monkeypatch) s = reload_config() From b3b4516375330297989676dd6e3538801b5df116 Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 12:26:52 +0900 Subject: [PATCH 61/65] fix: prevent duplicate prefix in streaming reply after roll-finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When StreamingMessage rolls to a new ts mid-stream (size-overflow path), the earlier ts holds the streamed prefix. stop() then overwrote the latest ts with chunks[0] of the full final_text, whose prefix overlapped the rolled message above it — producing two near-identical Slack messages followed by the suffix as a third. Track the rolled-finalized prefix in _finalized_text and slice it off at the start of stop() so the latest ts only receives the unwritten suffix. app.py now passes the full final_text to stop() and lets it own the split + follow-up postMessage so the prefix-aware slice can apply. Co-Authored-By: Claude Opus 4.7 (1M context) --- app.py | 18 ++++----- src/slack_helpers.py | 18 +++++++++ tests/test_slack_helpers.py | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index b6a41b7..6f8f8ed 100644 --- a/app.py +++ b/app.py @@ -370,17 +370,17 @@ def _on_step(step_num: int, phase: str, detail: dict) -> None: return final_text = result.text or "(응답을 생성하지 못했습니다)" - # Split the answer by Slack's per-message limit. StreamingMessage.stop() - # handles split internally when a placeholder exists; when it doesn't - # (no stream deltas ever arrived — e.g. a provider that returned content - # all at once), we post the chunks as fresh thread messages instead. - chunks = MessageFormatter.split_message(final_text, max_len=settings.max_len_slack) + # StreamingMessage.stop() handles split + follow-up postMessage internally, + # AND skips any prefix already sealed into earlier rolled ts'es by the + # size-overflow roll path. Pass full final_text so the slice can match. + # When no placeholder exists (no stream deltas ever arrived), post the + # chunks as fresh thread messages. if stream_msg.ts is not None: - stream_msg.stop(chunks[0]) + stream_msg.stop(final_text) else: - client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=chunks[0]) - for extra in chunks[1:]: - client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=extra) + chunks = MessageFormatter.split_message(final_text, max_len=settings.max_len_slack) + for chunk in chunks: + client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=chunk) # Explicitly clear the typing-style status indicator. Slack usually # auto-clears it when the bot posts a reply, but an explicit clear # ensures there's no stale line left over from the last on_step update. diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 2835373..cb24b79 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -182,6 +182,12 @@ def __init__( self._native = False # True once chat.startStream succeeds self._stopped = False self._consecutive_update_failures = 0 + # Concatenation of every prefix already sealed into earlier ts'es by + # the size-overflow roll-finalize path. stop() uses this to skip the + # part of final_text that is already on screen, otherwise the latest + # ts gets overwritten with a chunk whose content overlaps the rolled + # message above it ("first two messages are nearly identical"). + self._finalized_text = "" # -- start ---------------------------------------------------------- # @@ -251,10 +257,14 @@ def _flush(self) -> None: # behind a msg_too_long error on the next update. display = text + " " + self.placeholder if len(display) >= self.max_len: + sealed = False try: self.client.chat_update(channel=self.channel, ts=self.ts, text=text) + sealed = True except SlackApiError as exc: logger.warning("chat_update (roll-finalize) failed: %s", exc) + if sealed: + self._finalized_text += text self._roll_to_new_message() return try: @@ -355,6 +365,14 @@ def stop(self, final_text: str) -> None: return self._stopped = True + # If streaming rolled to a new ts via the size-overflow path, the + # earlier ts'es already display the prefix portion of the answer. + # Strip that prefix here so the latest ts gets only the suffix + # instead of overwriting itself with content already shown above + # (the "first two messages nearly identical" duplication). + if self._finalized_text and final_text.startswith(self._finalized_text): + final_text = final_text[len(self._finalized_text):] + if self._native: # Native streaming: stopStream accepts up to 12k chars, but be # conservative and split to self.max_len anyway to keep UX diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index ba7df8c..0f8c545 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -448,3 +448,76 @@ def test_streaming_message_stop_msg_too_long_deletes_placeholder(): assert len(answer_posts) == 1 +def test_streaming_message_stop_skips_already_rolled_prefix(): + """Regression: when streaming rolled to a new ts via the size-overflow + path, stop(final_text) must skip the prefix already sealed into the + earlier ts. Otherwise the latest ts gets overwritten with content that + overlaps the rolled message above it, producing two near-identical + messages (prefix in ts1, prefix in ts2, then the suffix as ts3).""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + {"ok": True, "ts": "ts3"}, + {"ok": True, "ts": "ts4"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=50 + ) + sm.start() + + # Stream ~60 chars to trigger one roll-finalize: ts1 sealed, ts2 placeholder. + prefix = "A" * 60 + sm.append(prefix) + assert sm.ts == "ts2" + + # Stream the suffix into ts2. + suffix = "B" * 30 + final_text = sm._finalized_text + suffix + sm.append(suffix) + + # Final compose call: pass the FULL streamed content. stop() should + # recognize the rolled prefix and only send the suffix to ts2. + sm.stop(final_text) + + # ts1 was rolled-finalized via chat_update with the prefix. + assert any( + call.kwargs.get("ts") == "ts1" and call.kwargs.get("text") == prefix + for call in client.chat_update.call_args_list + ) + # ts2 was finalized via chat_update with ONLY the suffix — never the + # prefix-overlapping content that caused the duplication bug. + ts2_finalize_calls = [ + call for call in client.chat_update.call_args_list + if call.kwargs.get("ts") == "ts2" and not call.kwargs.get("text", "").endswith(":robot_face:") + ] + assert ts2_finalize_calls, "expected a final chat_update on ts2" + for call in ts2_finalize_calls: + text = call.kwargs.get("text", "") + assert prefix not in text, f"ts2 finalize must not contain the rolled prefix: {text!r}" + + +def test_streaming_message_stop_unchanged_when_no_roll(): + """Without rolling, stop(final_text) should behave exactly as before: + one chat_update on the placeholder ts with the full text.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "only-ts"} + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=10_000 + ) + sm.start() + sm.stop("hello world") + + final_calls = [ + call for call in client.chat_update.call_args_list + if call.kwargs.get("text") == "hello world" + ] + assert len(final_calls) == 1 + + From 3f6f4771c5655d33ad675b023058ee4bc4fb12f8 Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 12:57:04 +0900 Subject: [PATCH 62/65] fix: split messages on paragraph boundary, fence-aware code blocks The previous splitter sliced on hard length boundaries when a paragraph overflowed, often cutting mid-word, and split code blocks naively which left users with unclosed ``` fences in some chunks. Replace with a greedy paragraph-first cut: 1. Cut at the last \n\n that fits inside max_len. 2. If the cut lands inside a code block, push the whole block to the next chunk by re-cutting at the \n\n right before the block opens. 3. If the block itself exceeds max_len, cut at the last \n\n inside the block (or hard-slice if none) and add \n``` / ```\n so every chunk stays self-balanced. 4. Fall back to sentence boundary, then hard slice, only when no \n\n is available within max_len at all. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/slack_helpers.py | 128 ++++++++++++++++++++---------------- tests/test_slack_helpers.py | 69 +++++++++++++++++-- 2 files changed, 134 insertions(+), 63 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index cb24b79..46a6790 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -38,11 +38,18 @@ def _slack_error_code(exc: SlackApiError) -> str: class MessageFormatter: """Split a long message into Slack-safe chunks. - Strategy (hierarchical): - 1. Prefer splitting on code fences so multi-line code blocks stay intact. - 2. Otherwise split on paragraph boundaries (\\n\\n). - 3. Otherwise split on sentence boundaries. - 4. Final fallback: hard slice at max_len. + Strategy (greedy, paragraph-first): + 1. Cut at the last \\n\\n that fits inside max_len. This keeps + sentences whole and avoids the "mid-word" splits that happen + with a hard slice. + 2. If that cut lands inside a ``` code block, push the whole + block to the next chunk by re-cutting at the \\n\\n right + before the block opens. + 3. If the block itself is larger than max_len, cut at the last + \\n\\n inside the block (or hard-slice if none), close the + current chunk with \\n``` and reopen the next chunk with ```\\n. + 4. When no \\n\\n is available within max_len at all, fall back + to a sentence boundary (.!? + whitespace), then hard-slice. """ @staticmethod @@ -52,64 +59,69 @@ def split_message(text: str, max_len: int = 2000) -> list[str]: if len(text) <= max_len: return [text] - if CODE_FENCE in text: - parts = text.split(CODE_FENCE) - chunks: list[str] = [] - for idx, part in enumerate(parts): - wrapped = f"{CODE_FENCE}{part}{CODE_FENCE}" if idx % 2 == 1 else part - if not wrapped: + chunks: list[str] = [] + remaining = text + fence_suffix = "\n" + CODE_FENCE + fence_prefix = CODE_FENCE + "\n" + + while len(remaining) > max_len: + # 1. Greedy paragraph-boundary cut within max_len. + cut = remaining.rfind(PARAGRAPH_SEP, 0, max_len) + if cut > 0: + first = remaining[:cut] + tail_start = cut + len(PARAGRAPH_SEP) + else: + first, tail_start = MessageFormatter._fallback_cut(remaining, max_len) + + # 2. Code-fence balancing: an odd ``` count means the cut + # landed inside a code block. + if first.count(CODE_FENCE) % 2 == 1: + last_fence = first.rfind(CODE_FENCE) + # Try pushing the whole block to the next chunk by + # cutting at the \n\n right before the block opens. + block_start_cut = first.rfind(PARAGRAPH_SEP, 0, last_fence) + if block_start_cut > 0: + first = remaining[:block_start_cut] + tail_start = block_start_cut + len(PARAGRAPH_SEP) + else: + # Block won't fit anywhere whole. Cut inside the block, + # leaving room for the closing fence so the chunk still + # respects max_len. + inner_budget = max_len - len(fence_suffix) + inner_cut = remaining.rfind(PARAGRAPH_SEP, 0, inner_budget) + if inner_cut > 0: + first = remaining[:inner_cut] + tail_start = inner_cut + len(PARAGRAPH_SEP) + else: + first = remaining[:inner_budget] + tail_start = inner_budget + chunks.append(first + fence_suffix) + remaining = fence_prefix + remaining[tail_start:] continue - chunks.extend(MessageFormatter._split_text(wrapped, max_len)) - return MessageFormatter._merge_small(chunks, max_len) - return MessageFormatter._split_text(text, max_len) + chunks.append(first) + remaining = remaining[tail_start:] - @staticmethod - def _split_text(text: str, max_len: int) -> list[str]: - if len(text) <= max_len: - return [text] - chunks: list[str] = [] - for paragraph in text.split(PARAGRAPH_SEP): - if len(paragraph) <= max_len: - chunks.append(paragraph) - continue - # paragraph too long: split by sentence - buf = "" - for sentence in SENTENCE_SPLIT_RE.split(paragraph): - if not sentence: - continue - candidate = f"{buf} {sentence}".strip() if buf else sentence - if len(candidate) > max_len: - if buf: - chunks.append(buf) - # sentence itself too long -> hard slice - while len(sentence) > max_len: - chunks.append(sentence[:max_len]) - sentence = sentence[max_len:] - buf = sentence - else: - buf = candidate - if buf: - chunks.append(buf) - return MessageFormatter._merge_small(chunks, max_len) + if remaining: + chunks.append(remaining) + + return chunks @staticmethod - def _merge_small(chunks: Iterable[str], max_len: int) -> list[str]: - out: list[str] = [] - buf = "" - for chunk in chunks: - if not chunk: - continue - candidate = f"{buf}{PARAGRAPH_SEP}{chunk}" if buf else chunk - if len(candidate) <= max_len: - buf = candidate - else: - if buf: - out.append(buf) - buf = chunk - if buf: - out.append(buf) - return out or [""] + def _fallback_cut(text: str, max_len: int) -> tuple[str, int]: + """Choose a cut when no \\n\\n boundary exists within max_len. + + Prefer the last sentence boundary inside max_len; otherwise + hard-slice at max_len. + """ + last_match = None + for match in SENTENCE_SPLIT_RE.finditer(text): + if match.start() >= max_len: + break + last_match = match + if last_match is not None: + return text[: last_match.start()], last_match.end() + return text[:max_len], max_len def set_thread_status(client: Any, channel: str, thread_ts: str, status: str) -> None: diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index 0f8c545..9a407d8 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -42,20 +42,79 @@ def test_split_keeps_small_code_blocks_intact(): def test_split_code_block_longer_than_max_len_still_respects_limit(): - """When a code block exceeds max_len, fences may not balance per chunk, - but no chunk may exceed max_len.""" + """When a code block exceeds max_len, the splitter cuts inside the + block, closes the current chunk with ``` and reopens the next one + with ```. Every chunk must still fit max_len AND be self-balanced.""" code = "```\n" + ("def x():\n return 1\n" * 100) + "```" chunks = MessageFormatter.split_message(code, max_len=500) assert all(len(c) <= 500 for c in chunks) - # Total fence count preserved across all chunks. - total_fences = sum(c.count("```") for c in chunks) - assert total_fences == 2 + # Each chunk closes its own code block — no chunk leaks an unclosed + # fence into the user's thread view. + assert all(c.count("```") % 2 == 0 for c in chunks) def test_split_empty_string(): assert MessageFormatter.split_message("", max_len=100) == [""] +def test_split_cuts_at_paragraph_boundary_not_mid_word(): + """The greedy \\n\\n cut must keep words and sentences whole rather + than slicing in the middle of a paragraph.""" + para1 = "First paragraph. " * 10 # ~170 chars + para2 = "Second paragraph. " * 10 # ~180 chars + para3 = "Third paragraph. " * 10 # ~170 chars + text = f"{para1}\n\n{para2}\n\n{para3}" + chunks = MessageFormatter.split_message(text, max_len=200) + # Every chunk should end at a paragraph or sentence boundary, never + # in the middle of a word like "para" + "graph". + for chunk in chunks: + assert not chunk.endswith("para"), f"mid-word cut: {chunk!r}" + # Trailing chars should be one of: full word, period+space, or end-of-text + assert chunk[-1] in ".!? \n" or chunk == chunks[-1] + + +def test_split_pushes_code_block_to_next_chunk_when_possible(): + """If the cut would land inside a code block but a \\n\\n exists + right before the block starts, push the whole block to the next + chunk so the block stays intact.""" + text = ( + "intro paragraph\n\n" + "another intro paragraph\n\n" + "```\n" + "def example():\n" + " return 42\n" + "```\n\n" + "epilogue" + ) + # Force a cut that initially lands inside the code block. + chunks = MessageFormatter.split_message(text, max_len=60) + # The code block must appear whole in some chunk — never split + # across two chunks when it could have been pushed. + block = "```\ndef example():\n return 42\n```" + assert any(block in c for c in chunks), f"block was split: {chunks}" + # All chunks fit and are self-balanced. + assert all(len(c) <= 60 for c in chunks) + assert all(c.count("```") % 2 == 0 for c in chunks) + + +def test_split_inside_code_block_uses_inner_paragraph_break(): + """When the block itself is too large, cut at the last \\n\\n + inside the block and re-fence both sides.""" + inner_para1 = "a " * 50 # ~100 chars + inner_para2 = "b " * 50 # ~100 chars + inner_para3 = "c " * 50 # ~100 chars + text = f"```\n{inner_para1}\n\n{inner_para2}\n\n{inner_para3}\n```" + chunks = MessageFormatter.split_message(text, max_len=150) + # Length budget respected on every chunk. + assert all(len(c) <= 150 for c in chunks), [len(c) for c in chunks] + # Every chunk is self-balanced: it opens with ``` and closes with ``` + # so the user never sees an unclosed code block. + for chunk in chunks: + assert chunk.count("```") % 2 == 0, f"unbalanced fence: {chunk!r}" + assert chunk.startswith("```") + assert chunk.rstrip().endswith("```") + + def test_user_name_cache_uses_display_name(): cache = UserNameCache._default() client = MagicMock() From 8f1e54b7fa3e12032ecaddf76363380a8db44bdb Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 16:42:41 +0900 Subject: [PATCH 63/65] fix: fall back to single \n when splitting code blocks and lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \n\n is rare inside fenced code blocks — they typically use only \n between lines — so the previous "split inside the block" path almost always hit the hard-slice fallback, cutting identifiers and tokens mid-word. Add a single-\n fallback to both cut paths: - Inside a too-large code block, after \n\n misses, look for the last \n within max_len (cap before the closing-fence budget). The cut must land after the opening fence so we always make progress. - In the general no-paragraph path (_fallback_cut), try sentence boundary first, then \n, then hard slice. This also fixes long lists that have \n separators but no .!? punctuation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/slack_helpers.py | 38 ++++++++++++++++++++++++++--------- tests/test_slack_helpers.py | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 46a6790..8ecd967 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -46,10 +46,13 @@ class MessageFormatter: block to the next chunk by re-cutting at the \\n\\n right before the block opens. 3. If the block itself is larger than max_len, cut at the last - \\n\\n inside the block (or hard-slice if none), close the - current chunk with \\n``` and reopen the next chunk with ```\\n. + \\n\\n inside the block; if the block uses only single \\n + between lines (the common case), cut at the last \\n instead. + Close the current chunk with \\n``` and reopen the next chunk + with ```\\n. 4. When no \\n\\n is available within max_len at all, fall back - to a sentence boundary (.!? + whitespace), then hard-slice. + to a sentence boundary (.!? + whitespace), then to a single + \\n, then hard-slice. """ @staticmethod @@ -86,15 +89,25 @@ def split_message(text: str, max_len: int = 2000) -> list[str]: else: # Block won't fit anywhere whole. Cut inside the block, # leaving room for the closing fence so the chunk still - # respects max_len. + # respects max_len. The cut must land after the opening + # fence and its newline so we always make progress. + min_cut = last_fence + len(CODE_FENCE) + 1 inner_budget = max_len - len(fence_suffix) - inner_cut = remaining.rfind(PARAGRAPH_SEP, 0, inner_budget) + inner_cut = remaining.rfind(PARAGRAPH_SEP, min_cut, inner_budget) if inner_cut > 0: first = remaining[:inner_cut] tail_start = inner_cut + len(PARAGRAPH_SEP) else: - first = remaining[:inner_budget] - tail_start = inner_budget + # Code blocks usually have only single \n between + # lines, not \n\n. Cut at a line boundary so a token + # doesn't get sliced. + line_cut = remaining.rfind("\n", min_cut, inner_budget) + if line_cut > 0: + first = remaining[:line_cut] + tail_start = line_cut + 1 + else: + first = remaining[:inner_budget] + tail_start = inner_budget chunks.append(first + fence_suffix) remaining = fence_prefix + remaining[tail_start:] continue @@ -111,8 +124,10 @@ def split_message(text: str, max_len: int = 2000) -> list[str]: def _fallback_cut(text: str, max_len: int) -> tuple[str, int]: """Choose a cut when no \\n\\n boundary exists within max_len. - Prefer the last sentence boundary inside max_len; otherwise - hard-slice at max_len. + Tries (in order): sentence boundary (.!? + whitespace), single + \\n, then hard slice. The single-\\n fallback handles content + like bullet lists or code blocks that only use one newline + between lines. """ last_match = None for match in SENTENCE_SPLIT_RE.finditer(text): @@ -121,6 +136,11 @@ def _fallback_cut(text: str, max_len: int) -> tuple[str, int]: last_match = match if last_match is not None: return text[: last_match.start()], last_match.end() + + line_cut = text.rfind("\n", 0, max_len) + if line_cut > 0: + return text[:line_cut], line_cut + 1 + return text[:max_len], max_len diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index 9a407d8..a1d0c29 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -115,6 +115,46 @@ def test_split_inside_code_block_uses_inner_paragraph_break(): assert chunk.rstrip().endswith("```") +def test_split_inside_code_block_falls_back_to_single_newline(): + """Real code blocks rarely contain \\n\\n — they use single \\n + between lines. When the block exceeds max_len, the splitter must + cut at a line boundary (\\n) so identifiers and tokens stay whole + instead of getting hard-sliced mid-word.""" + code = "```\n" + "\n".join(f"def function_number_{i}(): return {i}" for i in range(20)) + "\n```" + chunks = MessageFormatter.split_message(code, max_len=150) + assert all(len(c) <= 150 for c in chunks), [len(c) for c in chunks] + # Every chunk balanced: both ``` are inside the chunk. + for chunk in chunks: + assert chunk.count("```") % 2 == 0, f"unbalanced fence: {chunk!r}" + assert chunk.startswith("```") + assert chunk.rstrip().endswith("```") + # No identifier got cut in half. Every "function_number_" that + # appears must be complete (each cut should have landed on \n, + # never inside the identifier). + import re + + full_text = "\n".join(chunks) + # Each occurrence of "function_number_" must be followed by a digit + # and then "(" — otherwise it was sliced. + bad = re.findall(r"function_number_(?!\d+\()[^\n]{0,5}", full_text) + assert not bad, f"identifier sliced: {bad}" + + +def test_split_falls_back_to_single_newline_for_lists(): + """Lists or other line-based content without \\n\\n or sentence + punctuation should still cut at \\n rather than hard-slicing.""" + text = "\n".join(f"- item number {i} with some descriptive text" for i in range(20)) + chunks = MessageFormatter.split_message(text, max_len=150) + assert all(len(c) <= 150 for c in chunks), [len(c) for c in chunks] + # Every chunk should start and end at a list-item boundary, not + # mid-word. We verify by ensuring no chunk ends with a partial + # "item number " pattern. + for chunk in chunks[:-1]: + # Trailing content must be a complete item line, not split. + last_line = chunk.rstrip("\n").rsplit("\n", 1)[-1] + assert last_line.startswith("- item number "), f"mid-word cut: {last_line!r}" + + def test_user_name_cache_uses_display_name(): cache = UserNameCache._default() client = MagicMock() From 7b6f99dcc200b64846c59cfae41ba02341251c26 Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 17:06:57 +0900 Subject: [PATCH 64/65] fix: balance code-fence on streaming roll-finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the rolling buffer hits max_len mid-stream, the seal path was chat_update'ing the raw buffer and immediately rolling to a fresh placeholder. If the buffer ended inside an open ``` block, ts1 leaked an unclosed fence into Slack's renderer (the user saw the code block run past the message boundary, joining the next message visually) and ts2 began outside any fence even though the LLM was still streaming code. Fix: at roll time, if the buffer's ``` count is odd, append \n``` to the sealed text and pre-load the next placeholder's buffer with ```\n so streaming resumes inside a freshly opened block. The extra fences are NOT added to _finalized_text — that field still mirrors the raw LLM output so stop()'s prefix slice keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/slack_helpers.py | 16 +++++++- tests/test_slack_helpers.py | 78 ++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 8ecd967..29e6e09 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -289,15 +289,29 @@ def _flush(self) -> None: # behind a msg_too_long error on the next update. display = text + " " + self.placeholder if len(display) >= self.max_len: + # If the rolling buffer ends inside an unclosed code block, + # close it on this ts and reopen it on the next placeholder + # so each rolled message renders as a balanced block in + # Slack instead of leaking an unclosed ``` into thread + # rendering. + sealed_text = text + carry = "" + if text.count(CODE_FENCE) % 2 == 1: + sealed_text = text + "\n" + CODE_FENCE + carry = CODE_FENCE + "\n" sealed = False try: - self.client.chat_update(channel=self.channel, ts=self.ts, text=text) + self.client.chat_update(channel=self.channel, ts=self.ts, text=sealed_text) sealed = True except SlackApiError as exc: logger.warning("chat_update (roll-finalize) failed: %s", exc) if sealed: + # Track the raw (un-fenced) text so stop()'s + # final_text slice still matches the LLM output. self._finalized_text += text self._roll_to_new_message() + if sealed and carry: + self._buffer = carry return try: self.client.chat_update(channel=self.channel, ts=self.ts, text=display) diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index a1d0c29..7913aeb 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -297,7 +297,7 @@ def test_sanitize_error_redacts_aws_session_key(): # StreamingMessage # --------------------------------------------------------------------------- # -from src.slack_helpers import StreamingMessage +from src.slack_helpers import CODE_FENCE, StreamingMessage def _slack_client_native_stream(): @@ -599,6 +599,82 @@ def test_streaming_message_stop_skips_already_rolled_prefix(): assert prefix not in text, f"ts2 finalize must not contain the rolled prefix: {text!r}" +def test_streaming_message_roll_seals_unclosed_code_block_with_fence(): + """Regression: when the rolling buffer ends inside an open code + block, the seal must add a closing ``` so the rolled message + renders as a balanced block in Slack, and the next placeholder + must reopen the block with ``` so streaming continues inside the + same fence. Without this, ts1 leaks an unclosed ``` and the user + sees the code block run past the message boundary.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=50 + ) + sm.start() + + # Stream content that opens but never closes a code block, large + # enough to trigger a roll on the next flush. + open_block = "```python\n" + ("x" * 50) + sm.append(open_block) + + # ts1 was sealed via chat_update with a closing fence appended. + seal_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert seal_calls, "expected chat_update on ts1 during roll" + last_text = seal_calls[-1].kwargs["text"] + assert last_text.count(CODE_FENCE) % 2 == 0, f"ts1 still unbalanced: {last_text!r}" + assert last_text.endswith(CODE_FENCE), f"ts1 missing closing fence: {last_text!r}" + + # ts2 took over and its buffer was pre-loaded with ```\n so the + # next delta continues inside the reopened code block. + assert sm.ts == "ts2" + assert sm._buffer.startswith(CODE_FENCE), f"ts2 buffer missing carry-over: {sm._buffer!r}" + + # _finalized_text tracks the raw streamed text only — no extra + # closing fence — so stop()'s final_text slice still matches. + assert sm._finalized_text == open_block + + +def test_streaming_message_roll_no_carry_when_fence_already_balanced(): + """If the rolling buffer ends with the code block already closed, + no extra fence should be added on either side.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=50 + ) + sm.start() + + closed_block = "```\nshort code\n```\n" + ("y" * 40) + sm.append(closed_block) + + seal_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert seal_calls + last_text = seal_calls[-1].kwargs["text"] + # Sealed text equals the raw buffer — no synthetic closing fence. + assert last_text == closed_block + # ts2 buffer is empty (no carry). + assert sm._buffer == "" + + def test_streaming_message_stop_unchanged_when_no_roll(): """Without rolling, stop(final_text) should behave exactly as before: one chat_update on the placeholder ts with the full text.""" From 78bcc5eb580126e8df2c1c86716d655b1c6237e0 Mon Sep 17 00:00:00 2001 From: nalbam Date: Wed, 29 Apr 2026 17:20:04 +0900 Subject: [PATCH 65/65] fix: cut streaming roll at last newline, not mid-token The roll-finalize path used to seal whatever raw bytes happened to be in the buffer when it crossed max_len, splitting JS template literals (\`${body\`), identifiers, and markdown links across two Slack messages. Cut at the last \n\n or \n inside the buffer instead. The bytes between the cut point and the original buffer tail are carried into the next placeholder's buffer so streaming continues without losing any content. _finalized_text records only the cut prefix so stop()'s final_text slice still aligns with the LLM output. Also: when the rolled prefix ended inside an unclosed code block, stop() now reapplies a ```\n carry on the latest ts's chat_update so the suffix renders as code, not as plain text outside any fence. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/slack_helpers.py | 49 +++++++++++++----- tests/test_slack_helpers.py | 100 +++++++++++++++++++++++++++++++++--- 2 files changed, 130 insertions(+), 19 deletions(-) diff --git a/src/slack_helpers.py b/src/slack_helpers.py index 29e6e09..5d858a4 100644 --- a/src/slack_helpers.py +++ b/src/slack_helpers.py @@ -289,16 +289,35 @@ def _flush(self) -> None: # behind a msg_too_long error on the next update. display = text + " " + self.placeholder if len(display) >= self.max_len: - # If the rolling buffer ends inside an unclosed code block, - # close it on this ts and reopen it on the next placeholder - # so each rolled message renders as a balanced block in - # Slack instead of leaking an unclosed ``` into thread - # rendering. - sealed_text = text + # Cut at the last \n\n or \n so we don't seal mid-token + # (e.g. inside a JS template literal like `${body` or a + # half-streamed identifier). The buffer between the cut + # point and the seal is carried into the next placeholder. + cut = text.rfind(PARAGRAPH_SEP) + sep_len = len(PARAGRAPH_SEP) + if cut <= 0: + cut = text.rfind("\n") + sep_len = 1 + if cut > 0: + sealed_body = text[:cut] + rest = text[cut + sep_len:] + finalized_chunk = text[:cut + sep_len] + else: + # No newline anywhere — fall back to sealing the whole + # buffer (single-line content has no better option). + sealed_body = text + rest = "" + finalized_chunk = text + + # If the sealed body leaves an unclosed code block, close + # it here and reopen on the next placeholder so each rolled + # message renders as a balanced block in Slack. + sealed_text = sealed_body carry = "" - if text.count(CODE_FENCE) % 2 == 1: - sealed_text = text + "\n" + CODE_FENCE + if sealed_body.count(CODE_FENCE) % 2 == 1: + sealed_text = sealed_body + "\n" + CODE_FENCE carry = CODE_FENCE + "\n" + sealed = False try: self.client.chat_update(channel=self.channel, ts=self.ts, text=sealed_text) @@ -306,12 +325,12 @@ def _flush(self) -> None: except SlackApiError as exc: logger.warning("chat_update (roll-finalize) failed: %s", exc) if sealed: - # Track the raw (un-fenced) text so stop()'s + # Track the raw (un-fenced) prefix so stop()'s # final_text slice still matches the LLM output. - self._finalized_text += text + self._finalized_text += finalized_chunk self._roll_to_new_message() - if sealed and carry: - self._buffer = carry + if sealed: + self._buffer = carry + rest return try: self.client.chat_update(channel=self.channel, ts=self.ts, text=display) @@ -418,6 +437,12 @@ def stop(self, final_text: str) -> None: # (the "first two messages nearly identical" duplication). if self._finalized_text and final_text.startswith(self._finalized_text): final_text = final_text[len(self._finalized_text):] + # If the rolled prefix left us inside an unclosed code + # block, the next placeholder was carrying ```\n. Reapply + # that prefix so chat_update doesn't render the suffix as + # plain text outside any fence. + if self._finalized_text.count(CODE_FENCE) % 2 == 1: + final_text = CODE_FENCE + "\n" + final_text if self._native: # Native streaming: stopStream accepts up to 12k chars, but be diff --git a/tests/test_slack_helpers.py b/tests/test_slack_helpers.py index 7913aeb..9a7889b 100644 --- a/tests/test_slack_helpers.py +++ b/tests/test_slack_helpers.py @@ -638,10 +638,15 @@ def test_streaming_message_roll_seals_unclosed_code_block_with_fence(): # next delta continues inside the reopened code block. assert sm.ts == "ts2" assert sm._buffer.startswith(CODE_FENCE), f"ts2 buffer missing carry-over: {sm._buffer!r}" + # The carried-over content includes the rest of the buffer that + # came after the cut point — no token is dropped. + assert "x" * 50 in sm._buffer - # _finalized_text tracks the raw streamed text only — no extra - # closing fence — so stop()'s final_text slice still matches. - assert sm._finalized_text == open_block + # _finalized_text tracks the raw prefix up to (and including) the + # cut point so stop()'s final_text slice still matches the LLM + # output. Here the cut lands on the only \n in open_block, so the + # finalized prefix is "```python\n". + assert sm._finalized_text == "```python\n" def test_streaming_message_roll_no_carry_when_fence_already_balanced(): @@ -669,10 +674,91 @@ def test_streaming_message_roll_no_carry_when_fence_already_balanced(): ] assert seal_calls last_text = seal_calls[-1].kwargs["text"] - # Sealed text equals the raw buffer — no synthetic closing fence. - assert last_text == closed_block - # ts2 buffer is empty (no carry). - assert sm._buffer == "" + # Sealed text is the buffer prefix up to the cut point — no + # synthetic closing fence is appended because the prefix is + # already balanced. + assert last_text.count(CODE_FENCE) % 2 == 0 + assert "```" not in last_text[-3:] or last_text.endswith("```") # original closing fence kept + # No code-fence carry-over leaked into the new ts buffer because + # the sealed prefix was already balanced. + assert not sm._buffer.startswith(CODE_FENCE) + + +def test_streaming_message_roll_cuts_at_last_newline_not_mid_token(): + """Regression: the roll-finalize used to seal whatever raw bytes + happened to be in the buffer when it crossed max_len, splitting + JS template literals like `${body` mid-token. Cut at the last + \\n inside the buffer instead, and carry the post-cut bytes into + the next ts so the user sees a clean line boundary.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.side_effect = [ + {"ok": True, "ts": "ts1"}, + {"ok": True, "ts": "ts2"}, + ] + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=60 + ) + sm.start() + + # Stream content that crosses max_len with a mid-token tail. The + # buffer ends with `${body` — without the cut fix it would seal + # exactly there, breaking the template literal across two + # messages. + body = "first line of plain text\nconst base = `v0:${timestamp}:${body" + sm.append(body) + + seal_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert seal_calls, "expected chat_update on ts1 during roll" + sealed = seal_calls[-1].kwargs["text"] + + # ts1 must end at the \n boundary, not on the `${body` fragment. + assert "${body" not in sealed, f"mid-token cut: {sealed!r}" + assert sealed.endswith("text"), f"expected line cut, got: {sealed!r}" + + # The post-cut tail rides into ts2 so no streamed bytes are lost. + assert "${body" in sm._buffer + assert "const base" in sm._buffer + + +def test_streaming_message_stop_reapplies_fence_when_roll_left_open_block(): + """If the rolled prefix ended inside an unclosed code block, the + next ts was carrying ```\\n during streaming. stop() must reapply + that prefix on its chat_update so the latest ts doesn't render + the suffix as plain text outside the fence.""" + client = MagicMock() + client.api_call.side_effect = SlackApiError("no", {"error": "method_deprecated"}) + client.chat_postMessage.return_value = {"ok": True, "ts": "ts1"} + client.chat_update.return_value = {"ok": True} + + sm = StreamingMessage( + client=client, channel="C1", thread_ts="thread-1", min_interval=0.0, max_len=10_000 + ) + sm.start() + + # Simulate what _flush would do on roll: track an unclosed-block + # prefix in _finalized_text and a fresh ts in self.ts. + sm._finalized_text = "```python\n" + sm.ts = "ts1" + + full_answer = "```python\nbody = run()\nresult = body\n```" + sm.stop(full_answer) + + final_calls = [ + c for c in client.chat_update.call_args_list + if c.kwargs.get("ts") == "ts1" + ] + assert final_calls, "expected final chat_update on ts1" + body = final_calls[-1].kwargs["text"] + # The latest ts must reopen the block so its content renders as + # code, not as plain text. + assert body.startswith(CODE_FENCE), f"missing fence reopen: {body!r}" + assert body.count(CODE_FENCE) % 2 == 0, f"unbalanced final ts: {body!r}" def test_streaming_message_stop_unchanged_when_no_roll():