From 5f37d7f74353275ab9cece575f4f62a271350da4 Mon Sep 17 00:00:00 2001 From: Aman Kumar Sah Date: Thu, 29 Oct 2026 17:25:04 +0530 Subject: [PATCH 1/4] Add the LambdaTest gateway to incident-scribe and a one-command samples setup incident-scribe gains a `lambdatest` provider: an OpenAI-compatible chat-completions gateway called with HTTP Basic auth built from LT_USERNAME and LT_ACCESS_KEY, at GATEWAY_URL/v1/chat/completions. The other providers are unchanged, and it is auto-detected last. scripts/check_llm.py reports whether the keys are present, the endpoint is up (GET /healthz) and the credentials are accepted, without printing secrets. Exit codes: 0 ok, 1 not configured, 2 refused, 3 unreachable. samples/setup.sh and setup.ps1 set up every sample with one command: prerequisites, .env files, the incident-scribe token, embeddings and the model check. .env.example now defaults to the LambdaTest gateway. Co-Authored-By: Claude Sonnet 5.5 --- samples/README.md | 7 + samples/incident-scribe/.env.example | 19 ++- samples/incident-scribe/README.md | 4 +- samples/incident-scribe/docs/invoking.md | 1 + .../incident-scribe/incident_scribe/llm.py | 22 ++- samples/incident-scribe/scripts/check_llm.py | 121 ++++++++++++++ .../incident-scribe/test_incident_scribe.py | 38 +++++ samples/setup.ps1 | 135 +++++++++++++++ samples/setup.sh | 157 ++++++++++++++++++ 9 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 samples/incident-scribe/scripts/check_llm.py create mode 100644 samples/setup.ps1 create mode 100755 samples/setup.sh diff --git a/samples/README.md b/samples/README.md index 7dfc65c..3e7326d 100644 --- a/samples/README.md +++ b/samples/README.md @@ -15,6 +15,13 @@ The first is the declared case, and it is the minority. A survey of ~20 real repositories found **zero** agent manifests, which is why the folder is the input and every declaration is a bonus. +Set everything up with one command (installs dependencies, creates `.env` files, generates the incident-scribe token, installs embeddings, and checks the model key): + +```bash +./samples/setup.sh # macOS / Linux / WSL +powershell -ExecutionPolicy Bypass -File samples\setup.ps1 # Windows +``` + ```bash cd samples/refund-desk && rook # command-line agent cd samples/triage-service && npm start # then, in another terminal: diff --git a/samples/incident-scribe/.env.example b/samples/incident-scribe/.env.example index da41021..19e22c3 100644 --- a/samples/incident-scribe/.env.example +++ b/samples/incident-scribe/.env.example @@ -3,11 +3,20 @@ # Any secret string you choose; every CLI command must pass it as --token. SCRIBE_API_TOKEN= -# One model provider. GLM through Z.AI's Anthropic-compatible endpoint, for example: -LLM_PROVIDER=glm -ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic -GLM_API_KEY= -GLM_MODEL= +# One model provider. The LambdaTest gateway (OpenAI-compatible chat completions, +# HTTP Basic auth). GATEWAY_URL is the base host only: the desk calls +# /v1/chat/completions and checks /healthz. +LLM_PROVIDER=lambdatest +GATEWAY_URL=https://rook-samples.lambdatest.com +LT_USERNAME= +LT_ACCESS_KEY= +LT_MODEL=auto + +# Or GLM through Z.AI's Anthropic-compatible endpoint (comment out the block above first): +# LLM_PROVIDER=glm +# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic +# GLM_API_KEY= +# GLM_MODEL= # Or Gemini: # LLM_PROVIDER=gemini diff --git a/samples/incident-scribe/README.md b/samples/incident-scribe/README.md index 45561e8..4a9d139 100644 --- a/samples/incident-scribe/README.md +++ b/samples/incident-scribe/README.md @@ -11,6 +11,7 @@ An incident-intake agent to test with `rook`. ```bash cp .env.example .env # add a token and one model provider's key +python3 scripts/check_llm.py # keys present? endpoint up? credentials accepted? (Windows: python) ./scripts/preflight.sh # Windows: powershell -File scripts\preflight.ps1 rook # explore the agent and generate a suite ``` @@ -260,7 +261,7 @@ variables win over `.env`. | variable | role | | --- | --- | | `SCRIBE_API_TOKEN` | **required**: the credential checked on every command | -| `LLM_PROVIDER` | `anthropic`/`glm` · `gemini` · `responses` · `openai`/`chat`. If unset, auto-detected in that order from the variables present | +| `LLM_PROVIDER` | `anthropic`/`glm` · `gemini` · `responses` · `lambdatest`/`lt` · `openai`/`chat`. If unset, auto-detected in that order from the variables present | | `SCRIBE_STATE_DIR` | session storage root (default `.scribe-sessions/`) | | `OPS_LEARNED_FILE` | learned-incidents file (default `services/incidents.learned.json`) | | `MCP_TIMEOUT` | per-call timeout to ops-directory, seconds (default 30) | @@ -273,6 +274,7 @@ Per provider: | `anthropic` / `glm` | `ANTHROPIC_BASE_URL` (default `https://api.z.ai/api/anthropic`); key from `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`, `GLM_API_KEY` or `ZAI_API_KEY`; model from `ANTHROPIC_MODEL`, `GLM_MODEL` or `ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU}_MODEL` (a `[1m]` suffix is stripped) | | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL`; optional `GEMINI_ENDPOINT` | | `responses` | `OPENAI_ENDPOINT`, `OPENAI_INDIA_API_KEY`, `OPENAI_MODEL` | +| `lambdatest` / `lt` | `GATEWAY_URL`, `LT_USERNAME`, `LT_ACCESS_KEY` (sent as HTTP Basic auth to `/v1/chat/completions`); optional `LT_MODEL` (default `auto`) | | `openai` / `chat` | `OPENAI_API_KEY` (or `OPENAI_INDIA_API_KEY`), `OPENAI_CHAT_MODEL` (or `OPENAI_MODEL`); optional `OPENAI_CHAT_ENDPOINT` / `OPENAI_BASE_URL` | ### Project layout diff --git a/samples/incident-scribe/docs/invoking.md b/samples/incident-scribe/docs/invoking.md index 3b542bd..8d7677e 100644 --- a/samples/incident-scribe/docs/invoking.md +++ b/samples/incident-scribe/docs/invoking.md @@ -79,6 +79,7 @@ Other supported providers use the same multi-turn agent loop: | `glm` / `anthropic` | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` | | `openai` / `chat` | `OPENAI_API_KEY`, `OPENAI_CHAT_MODEL`; optional `OPENAI_CHAT_ENDPOINT` | | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL`; optional `GEMINI_ENDPOINT` | +| `lambdatest` / `lt` | `GATEWAY_URL`, `LT_USERNAME`, `LT_ACCESS_KEY`; optional `LT_MODEL` (default `auto`) | If `LLM_PROVIDER` is omitted, the adapter selects Anthropic/GLM, Gemini, Responses, or Chat Completions from the variables that are present, in that diff --git a/samples/incident-scribe/incident_scribe/llm.py b/samples/incident-scribe/incident_scribe/llm.py index a8c9a81..4fdae90 100644 --- a/samples/incident-scribe/incident_scribe/llm.py +++ b/samples/incident-scribe/incident_scribe/llm.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json import os import time @@ -18,6 +19,7 @@ class ModelError(RuntimeError): RESPONSES = {"responses", "azure", "azure_responses", "openai_responses"} CHAT = {"chat", "chat_completions", "openai", "openai_chat"} GEMINI = {"gemini", "google", "google_gemini"} +LAMBDATEST = {"lambdatest", "lt", "gateway"} def _config() -> tuple[str, str, str, str]: @@ -29,6 +31,8 @@ def _config() -> tuple[str, str, str, str]: requested = "gemini" elif os.environ.get("OPENAI_ENDPOINT", "").strip(): requested = "responses" + elif all(os.environ.get(name, "").strip() for name in ("GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY")): + requested = "lambdatest" else: requested = "chat" @@ -54,6 +58,18 @@ def _config() -> tuple[str, str, str, str]: raise ModelError("the Gemini model needs GEMINI_API_KEY and GEMINI_MODEL") return "gemini", endpoint.rstrip("/"), key, model + if requested in LAMBDATEST: + endpoint = os.environ.get("GATEWAY_URL", "").strip() + username = os.environ.get("LT_USERNAME", "").strip() + access_key = os.environ.get("LT_ACCESS_KEY", "").strip() + model = os.environ.get("LT_MODEL", "").strip() or "auto" + missing = [name for name, value in ( + ("GATEWAY_URL", endpoint), ("LT_USERNAME", username), ("LT_ACCESS_KEY", access_key) + ) if not value] + if missing: + raise ModelError("the LambdaTest gateway is not configured: " + ", ".join(missing)) + return "lambdatest", _chat_url(endpoint), base64.b64encode(f"{username}:{access_key}".encode()).decode(), model + if requested in CHAT: endpoint = os.environ.get("OPENAI_CHAT_ENDPOINT", "").strip() if not endpoint: @@ -271,11 +287,11 @@ def _call_anthropic(items: list[dict[str, Any]], tools: list[dict[str, Any]] | N return {"text": "\n".join(block.get("text", "") for block in blocks if block.get("type") == "text").strip(), "tool_calls": calls, "raw_output": [{"role": "assistant", "content": blocks}], "stop_reason": data.get("stop_reason"), "usage": {"input_tokens": usage.get("input_tokens", 0), "output_tokens": usage.get("output_tokens", 0)}} -def _call_chat(items: list[dict[str, Any]], tools: list[dict[str, Any]] | None, endpoint: str, key: str, model: str, max_tokens: int) -> dict[str, Any]: +def _call_chat(items: list[dict[str, Any]], tools: list[dict[str, Any]] | None, endpoint: str, key: str, model: str, max_tokens: int, scheme: str = "Bearer") -> dict[str, Any]: body: dict[str, Any] = {"model": model, "messages": _chat_items(items), "max_tokens": max_tokens} if tools: body["tools"] = _chat_tools(tools) - data = _request(endpoint, body, {"content-type": "application/json", "Authorization": f"Bearer {key}"}) + data = _request(endpoint, body, {"content-type": "application/json", "Authorization": f"{scheme} {key}"}) message = (data.get("choices") or [{}])[0].get("message") or {} calls = [] for call in message.get("tool_calls") or []: @@ -314,6 +330,8 @@ def call(items: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, return _call_anthropic(items, tools, endpoint, key, model, max_output_tokens) if provider == "chat": return _call_chat(items, tools, endpoint, key, model, max_output_tokens) + if provider == "lambdatest": + return _call_chat(items, tools, endpoint, key, model, max_output_tokens, scheme="Basic") if provider == "gemini": return _call_gemini(items, tools, endpoint, key, model, max_output_tokens) diff --git a/samples/incident-scribe/scripts/check_llm.py b/samples/incident-scribe/scripts/check_llm.py new file mode 100644 index 0000000..a2d9b65 --- /dev/null +++ b/samples/incident-scribe/scripts/check_llm.py @@ -0,0 +1,121 @@ +"""Prove the model provider in `.env` works: keys present, endpoint up, auth accepted. + + python scripts/check_llm.py # all three checks + python scripts/check_llm.py --config-only # keys only, no network call + +Exit codes: 0 all good · 1 not configured · 2 endpoint up but credentials +refused · 3 endpoint unreachable or the request was rejected. + +Secrets are never printed: only the NAMES of the variables that are set. +""" + +from __future__ import annotations + +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from incident_scribe.__main__ import _load_dotenv # noqa: E402 +from incident_scribe import llm # noqa: E402 + +_load_dotenv() + +# Variable names each provider reads, so the report can say which are set. +VARIABLES = { + "anthropic": ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "ANTHROPIC_MODEL", "GLM_MODEL"), + "gemini": ("GEMINI_API_KEY", "GEMINI_MODEL", "GEMINI_ENDPOINT"), + "chat": ("OPENAI_API_KEY", "OPENAI_INDIA_API_KEY", "OPENAI_CHAT_MODEL", "OPENAI_MODEL", "OPENAI_CHAT_ENDPOINT", "OPENAI_BASE_URL"), + "responses": ("OPENAI_ENDPOINT", "OPENAI_INDIA_API_KEY", "OPENAI_MODEL"), + "lambdatest": ("GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY", "LT_MODEL"), +} + + +def _paint(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if sys.stdout.isatty() and "NO_COLOR" not in os.environ else text + + +def ok(message: str) -> None: + print(f" {_paint('32', 'ok')} {message}") + + +def bad(message: str) -> None: + print(f" {_paint('31', '!!')} {message}") + + +def info(message: str) -> None: + print(f" {message}") + + +def gateway_health(endpoint: str) -> bool: + """GET /healthz — unauthenticated, so it isolates "is it up" from "are my keys good".""" + parts = urllib.parse.urlsplit(endpoint) + url = f"{parts.scheme}://{parts.netloc}/healthz" + try: + with urllib.request.urlopen(url, timeout=15) as response: + ok(f"health: {url} answered {response.status}") + return True + except urllib.error.HTTPError as error: + bad(f"health: {url} answered {error.code}") + except (urllib.error.URLError, TimeoutError, OSError) as error: + bad(f"health: {url} unreachable — {error}") + return False + + +def main() -> int: + config_only = "--config-only" in sys.argv[1:] + + # 1. keys present + try: + provider, endpoint, _key, model = llm._config() + except llm.ModelError as error: + bad(f"no usable model configuration: {error}") + return 1 + present = [name for name in VARIABLES.get(provider, ()) if os.environ.get(name, "").strip()] + ok(f"provider '{provider}', model '{model}', endpoint {endpoint}") + ok("credentials present: " + ", ".join(present)) + if config_only: + return 0 + + # 2. health. The gateway has a dedicated probe; other providers are judged + # by the request below, since a 401 from them already proves they are up. + probed = provider == "lambdatest" + if probed and not gateway_health(endpoint): + return 3 + + # 3. one tiny real request. Any HTTP answer, even a refusal, proves the + # endpoint is up; only a 2xx proves the credentials are accepted. + os.environ.setdefault("LLM_RETRIES", "0") + started = time.monotonic() + try: + llm.call([llm.user("Reply with the single word: ok")], max_output_tokens=64) + except llm.ModelError as error: + message = str(error) + answered = re.search(r"model answered (\d{3})", message) + if answered and answered.group(1) in {"401", "403"}: + if not probed: + ok(f"health: endpoint is up ({answered.group(1)} answered)") + bad(f"auth: credentials were refused — check the key/username in .env ({message[:200]})") + return 2 + if answered: + if not probed: + ok(f"health: endpoint is up ({answered.group(1)} answered)") + bad(f"auth: could not be confirmed, the request was rejected — {message[:300]}") + return 3 + bad(f"health: endpoint unreachable — {message[:300]}") + return 3 + seconds = time.monotonic() - started + if not probed: + ok(f"health: endpoint is up ({seconds:.1f}s round trip)") + ok(f"auth: credentials accepted, the model answered ({seconds:.1f}s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/incident-scribe/test_incident_scribe.py b/samples/incident-scribe/test_incident_scribe.py index 94b15f9..019642a 100644 --- a/samples/incident-scribe/test_incident_scribe.py +++ b/samples/incident-scribe/test_incident_scribe.py @@ -263,3 +263,41 @@ def test_chat_and_gemini_tool_shapes(monkeypatch): result = llm.call([llm.user("find")], tools=[{"name": "lookup", "parameters": {"type": "object"}}]) _, contents = _gemini_items(result["raw_output"] + [tool_result(result["tool_calls"][0]["call_id"], {"ok": True})]) assert contents[-1]["parts"][0]["functionResponse"]["name"] == "lookup" + + +def test_lambdatest_gateway_uses_basic_auth(monkeypatch): + import base64 + from incident_scribe import llm + + for name in ("LLM_TIMEOUT", "LT_MODEL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("LLM_PROVIDER", "lambdatest") + monkeypatch.setenv("GATEWAY_URL", "https://gateway.example.com") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.setenv("LT_ACCESS_KEY", "s3cret") + provider, endpoint, key, model = _config() + assert (provider, endpoint, model) == ("lambdatest", "https://gateway.example.com/v1/chat/completions", "auto") + assert base64.b64decode(key).decode() == "alice:s3cret" + + seen = {} + + def fake_request(url, body, headers): + seen.update(url=url, body=body, headers=headers) + return {"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], "usage": {}} + + monkeypatch.setattr(llm, "_request", fake_request) + assert llm.call([llm.user("hi")])["text"] == "ok" + assert seen["headers"]["Authorization"] == f"Basic {key}" + assert seen["body"]["model"] == "auto" and "stream" not in seen["body"] + + +def test_lambdatest_gateway_needs_all_three_settings(monkeypatch): + import pytest + from incident_scribe.llm import ModelError + + monkeypatch.setenv("LLM_PROVIDER", "lt") + monkeypatch.setenv("GATEWAY_URL", "https://gateway.example.com") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.delenv("LT_ACCESS_KEY", raising=False) + with pytest.raises(ModelError, match="LT_ACCESS_KEY"): + _config() diff --git a/samples/setup.ps1 b/samples/setup.ps1 new file mode 100644 index 0000000..ebf32e2 --- /dev/null +++ b/samples/setup.ps1 @@ -0,0 +1,135 @@ +# +# One-command setup for the sample agents on Windows - the twin of setup.sh. +# Runs on Windows PowerShell 5.1 and PowerShell 7. +# +# powershell -ExecutionPolicy Bypass -File setup.ps1 set up EVERYTHING (the one command; run it from anywhere) +# powershell -ExecutionPolicy Bypass -File setup.ps1 incident-scribe or just the samples you name +# powershell -ExecutionPolicy Bypass -File setup.ps1 -NoSemantic skip the embeddings download +# +# Safe to re-run: an existing .env is kept, and a token is only generated where +# the file has none. +# +# What each sample needs: +# incident-scribe Python 3.9+, Node, a .env with a token and a model provider +# industry-agents Node 22+, npm install, one .env per demo +# refund-desk, triage-service, knowledge-vault, trip-weather-station +# Node only - nothing to install + +param( + [switch]$NoSemantic, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$Samples +) + +Set-Location $PSScriptRoot + +$All = 'incident-scribe', 'industry-agents', 'refund-desk', 'triage-service', 'knowledge-vault', 'trip-weather-station' + +function Ok($msg) { Write-Host " " -NoNewline; Write-Host "ok" -ForegroundColor Green -NoNewline; Write-Host " $msg" } +function Bad($msg) { Write-Host " " -NoNewline; Write-Host "!!" -ForegroundColor Red -NoNewline; Write-Host " $msg" } +function Info($msg) { Write-Host " $msg" } +function Head($msg) { Write-Host ""; Write-Host $msg -ForegroundColor White } + +if (-not $Samples) { $Samples = $All } +foreach ($s in $Samples) { + if ($All -notcontains $s) { Bad "unknown sample: $s (choose from: $($All -join ', '))"; exit 2 } +} + +$failed = @() + +# `python3` on Windows is often the Microsoft Store stub; `python` is the real one. +$python = $null +foreach ($candidate in 'python', 'python3') { + $cmd = Get-Command $candidate -ErrorAction SilentlyContinue + if ($cmd) { + & $cmd.Source -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)" 2>$null + if ($LASTEXITCODE -eq 0) { $python = $cmd.Source; break } + } +} + +Head "sample agents - setup" + +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Bad "node is not on PATH (https://nodejs.org)"; exit 1 } +$nodeMajor = [int](node -p "process.versions.node.split('.')[0]") +Ok "node $(node --version)" + +function Setup-IncidentScribe { + Head "incident-scribe" + if (-not $python) { Bad "Python 3.9+ is not on PATH (python / python3)"; $script:failed += 'incident-scribe'; return } + Ok (& $python --version) + + Push-Location incident-scribe + try { + if (-not (Test-Path .env)) { + Copy-Item .env.example .env + Ok "created .env from .env.example" + } else { + Ok "kept existing .env" + } + + $envText = Get-Content .env -Raw + if ($envText -match '(?m)^SCRIBE_API_TOKEN=[ \t]*\r?$') { + $token = (& $python -c "import secrets; print(secrets.token_urlsafe(24))").Trim() + $envText = [regex]::Replace($envText, '(?m)^SCRIBE_API_TOKEN=[ \t]*(?=\r?$)', "SCRIBE_API_TOKEN=$token", 1) + # no BOM: the Python reader would otherwise see it as part of the first key + [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.env'), $envText, (New-Object System.Text.UTF8Encoding($false))) + Ok "generated SCRIBE_API_TOKEN in .env" + } else { + Ok "SCRIBE_API_TOKEN already set" + } + + if (-not $NoSemantic) { + Info "installing embeddings (downloads a model, a few minutes)..." + npm install --no-audit --no-fund *> $null + $installed = ($LASTEXITCODE -eq 0) + if ($installed) { node scripts/build-index.mjs *> $null; $installed = ($LASTEXITCODE -eq 0) } + if ($installed) { Ok "semantic search armed" } else { Bad "embedding install failed - search_incidents will fall back to lexical mode" } + } else { + Info "search_incidents will run in lexical mode (-NoSemantic was given; re-run without it for embeddings)" + } + + # keys present -> endpoint up -> credentials accepted (exit 1 / 3 / 2 when not) + & $python scripts\check_llm.py + switch ($LASTEXITCODE) { + 0 { + & powershell -NoProfile -ExecutionPolicy Bypass -File scripts\preflight.ps1 + if ($LASTEXITCODE -ne 0) { $script:failed += 'incident-scribe' } + } + 1 { + Info "next: open incident-scribe\.env and fill in LT_USERNAME and LT_ACCESS_KEY" + Info " (GATEWAY_URL is already https://rook-samples.lambdatest.com; to use GLM or Gemini instead, swap the provider block)." + Info " then re-run setup.ps1 incident-scribe" + } + default { Bad "the model provider is configured but did not pass - fix .env and re-run"; $script:failed += 'incident-scribe' } + } + } finally { + Pop-Location + } +} + +function Setup-IndustryAgents { + Head "industry-agents" + if ($nodeMajor -lt 22) { Bad "needs Node 22+, found $(node --version)"; $script:failed += 'industry-agents'; return } + Push-Location industry-agents + try { + npm install --no-audit --no-fund *> $null + if ($LASTEXITCODE -ne 0) { Bad "npm install failed"; $script:failed += 'industry-agents'; return } + Ok "dependencies installed" + npm run setup --silent | ForEach-Object { Info $_ } + Info "next: add MODEL_API_KEY to the demo .env you want to run (demos\*\.env)" + } finally { + Pop-Location + } +} + +foreach ($sample in $Samples) { + switch ($sample) { + 'incident-scribe' { Setup-IncidentScribe } + 'industry-agents' { Setup-IndustryAgents } + default { Head $sample; Ok "nothing to install" } + } +} + +Write-Host "" +if ($failed.Count -gt 0) { Bad "needs attention: $($failed -join ', ')"; exit 1 } +Info "done." +Write-Host "" diff --git a/samples/setup.sh b/samples/setup.sh new file mode 100755 index 0000000..6d2660f --- /dev/null +++ b/samples/setup.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# One-command setup for the sample agents (macOS, Linux, WSL). The Windows twin +# is setup.ps1. +# +# ./setup.sh set up EVERYTHING (the one command; run it from anywhere) +# ./setup.sh incident-scribe or just the samples you name +# ./setup.sh --no-semantic skip the embeddings download for incident-scribe +# ./setup.sh --help +# +# Safe to re-run: an existing .env is kept, and a token is only generated where +# the file has none. +# +# What each sample needs: +# incident-scribe Python 3.9+, Node, a .env with a token and a model provider +# industry-agents Node 22+, npm install, one .env per demo +# refund-desk, triage-service, knowledge-vault, trip-weather-station +# Node only — nothing to install + +set -euo pipefail +cd "$(dirname "$0")" + +ALL="incident-scribe industry-agents refund-desk triage-service knowledge-vault trip-weather-station" +SEMANTIC=1 +SELECTED="" + +green() { printf ' \033[32m%s\033[0m %s\n' "ok" "$1"; } +red() { printf ' \033[31m%s\033[0m %s\n' "!!" "$1"; } +info() { printf ' %s\n' "$1"; } +head_() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +for arg in "$@"; do + case "$arg" in + --semantic) SEMANTIC=1 ;; + --no-semantic) SEMANTIC=0 ;; + -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) red "unknown option: $arg"; exit 2 ;; + *) + case " $ALL " in + *" $arg "*) SELECTED="$SELECTED $arg" ;; + *) red "unknown sample: $arg (choose from: $ALL)"; exit 2 ;; + esac ;; + esac +done +SELECTED="${SELECTED:-$ALL}" + +wants() { case " $SELECTED " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +# --- prerequisites --------------------------------------------------------- +FAILED="" + +PY="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1 \ + && "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)' 2>/dev/null; then + PY="$candidate"; break + fi +done + +NODE_MAJOR="" +if command -v node >/dev/null 2>&1; then + NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" +fi + +head_ "sample agents — setup" + +if [ -n "$NODE_MAJOR" ]; then green "node $(node --version)"; else red "node is not on PATH (https://nodejs.org)"; exit 1; fi + +# --- incident-scribe ------------------------------------------------------- +setup_incident_scribe() { + head_ "incident-scribe" + if [ -z "$PY" ]; then red "Python 3.9+ is not on PATH (python3 / python)"; FAILED="$FAILED incident-scribe"; return; fi + green "$($PY --version)" + + cd incident-scribe + if [ ! -f .env ]; then + cp .env.example .env + green "created .env from .env.example" + else + green "kept existing .env" + fi + + if grep -Eq '^SCRIBE_API_TOKEN=[[:space:]]*$' .env; then + local token + token="$($PY -c 'import secrets; print(secrets.token_urlsafe(24))')" + "$PY" - .env "$token" <<'EOF' +import re, sys +path, token = sys.argv[1:3] +text = open(path).read() +open(path, "w").write(re.sub(r"(?m)^SCRIBE_API_TOKEN=[ \t]*$", "SCRIBE_API_TOKEN=" + token, text, count=1)) +EOF + green "generated SCRIBE_API_TOKEN in .env" + else + green "SCRIBE_API_TOKEN already set" + fi + + if [ "$SEMANTIC" = 1 ]; then + info "installing embeddings (downloads a model, a few minutes)..." + if npm install --no-audit --no-fund >/dev/null && node scripts/build-index.mjs >/dev/null; then + green "semantic search armed" + else + red "embedding install failed — search_incidents will fall back to lexical mode" + fi + else + info "search_incidents will run in lexical mode (--no-semantic was given; re-run without it for embeddings)" + fi + + # keys present -> endpoint up -> credentials accepted (exit 1 / 3 / 2 when not) + local status=0 + "$PY" scripts/check_llm.py || status=$? + case "$status" in + 0) bash scripts/preflight.sh || FAILED="$FAILED incident-scribe" ;; + 1) + info "next: open incident-scribe/.env and fill in LT_USERNAME and LT_ACCESS_KEY" + info " (GATEWAY_URL is already https://rook-samples.lambdatest.com; to use GLM or Gemini instead, swap the provider block)." + info " then re-run ./setup.sh incident-scribe" ;; + *) red "the model provider is configured but did not pass — fix .env and re-run"; FAILED="$FAILED incident-scribe" ;; + esac + cd .. +} + +# --- industry-agents ------------------------------------------------------- +setup_industry_agents() { + head_ "industry-agents" + if [ "$NODE_MAJOR" -lt 22 ]; then red "needs Node 22+, found $(node --version)"; FAILED="$FAILED industry-agents"; return; fi + cd industry-agents + if npm install --no-audit --no-fund >/dev/null; then + green "dependencies installed" + else + red "npm install failed"; FAILED="$FAILED industry-agents"; cd ..; return + fi + npm run setup --silent | while IFS= read -r line; do info "$line"; done + info "next: add MODEL_API_KEY to the demo .env you want to run (demos/*/.env)" + cd .. +} + +# --- node-only samples ----------------------------------------------------- +setup_node_only() { + head_ "$1" + green "nothing to install" +} + +for sample in $SELECTED; do + case "$sample" in + incident-scribe) setup_incident_scribe ;; + industry-agents) setup_industry_agents ;; + *) setup_node_only "$sample" ;; + esac +done + +echo +if [ -n "$FAILED" ]; then + red "needs attention:$FAILED" + exit 1 +fi +info "done." +echo From d3815c420a06afc1ac8c7d59134562a415943b34 Mon Sep 17 00:00:00 2001 From: Aman Kumar Sah Date: Thu, 29 Oct 2026 18:03:18 +0530 Subject: [PATCH 2/4] Pin the incident-scribe gateway review findings with failing tests Auto-detect precedence, cleartext credentials, HTTP status on ModelError, token generation edge cases, check_llm exit codes and the health probe path. These fail on the previous commit and are fixed in the next one. Co-Authored-By: Claude Sonnet 5.5 --- .../incident-scribe/test_incident_scribe.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/samples/incident-scribe/test_incident_scribe.py b/samples/incident-scribe/test_incident_scribe.py index 019642a..a2a90bc 100644 --- a/samples/incident-scribe/test_incident_scribe.py +++ b/samples/incident-scribe/test_incident_scribe.py @@ -301,3 +301,150 @@ def test_lambdatest_gateway_needs_all_three_settings(monkeypatch): monkeypatch.delenv("LT_ACCESS_KEY", raising=False) with pytest.raises(ModelError, match="LT_ACCESS_KEY"): _config() + + +_PROVIDER_VARS = ( + "LLM_PROVIDER", "ANTHROPIC_BASE_URL", "GLM_API_KEY", "ZAI_API_KEY", "GEMINI_API_KEY", "OPENAI_ENDPOINT", + "OPENAI_API_KEY", "OPENAI_INDIA_API_KEY", "OPENAI_CHAT_MODEL", "OPENAI_MODEL", "OPENAI_CHAT_ENDPOINT", + "OPENAI_BASE_URL", "GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY", "LT_MODEL", +) + + +def _clean_provider_env(monkeypatch): + for name in _PROVIDER_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_lambdatest_is_autodetected_after_the_chat_keys(monkeypatch): + _clean_provider_env(monkeypatch) + monkeypatch.setenv("GATEWAY_URL", "https://gateway.example.com") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.setenv("LT_ACCESS_KEY", "s3cret") + assert _config()[0] == "lambdatest" + + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + monkeypatch.setenv("OPENAI_CHAT_MODEL", "gpt-x") + assert _config()[0] == "chat" + + +def test_lambdatest_sends_credentials_only_over_https_or_loopback(monkeypatch): + import pytest + from incident_scribe.llm import ModelError + + _clean_provider_env(monkeypatch) + monkeypatch.setenv("LLM_PROVIDER", "lambdatest") + monkeypatch.setenv("LT_USERNAME", "alice") + monkeypatch.setenv("LT_ACCESS_KEY", "s3cret") + for url in ("http://gateway.example.com", "gateway.example.com"): + monkeypatch.setenv("GATEWAY_URL", url) + with pytest.raises(ModelError, match="https"): + _config() + for url in ("http://127.0.0.1:8080", "http://localhost:8080", "https://gateway.example.com"): + monkeypatch.setenv("GATEWAY_URL", url) + assert _config()[0] == "lambdatest" + + +def test_model_error_carries_the_http_status(monkeypatch): + import io + import urllib.error + from incident_scribe import llm + + monkeypatch.setenv("LLM_RETRIES", "0") + + def refused(req, timeout): + raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, io.BytesIO(b"no")) + + monkeypatch.setattr(llm.urllib.request, "urlopen", refused) + try: + llm._request("https://gateway.example.com", {}, {}) + except llm.ModelError as error: + assert error.status == 401 + else: + raise AssertionError("expected ModelError") + + class Answer(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr(llm.urllib.request, "urlopen", lambda req, timeout: Answer(b'{"error": "bad key"}')) + try: + llm._request("https://gateway.example.com", {}, {}) + except llm.ModelError as error: + assert error.status == 200 + else: + raise AssertionError("expected ModelError") + + +def _script(name): + import importlib.util + from pathlib import Path + + spec = importlib.util.spec_from_file_location(name, Path(__file__).parent / "scripts" / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_ensure_token_fills_every_way_a_token_can_be_missing(): + ensure = _script("ensure_token").ensure + for text in ("SCRIBE_API_TOKEN=\n", 'SCRIBE_API_TOKEN=""\n', "# SCRIBE_API_TOKEN=\nLLM_PROVIDER=x\n", "LLM_PROVIDER=x\n", ""): + new, generated = ensure(text) + assert generated + value = [line for line in new.splitlines() if line.startswith("SCRIBE_API_TOKEN=")][0].split("=", 1)[1] + assert len(value) == 48 and not value.startswith("-") + + kept, generated = ensure("A=1\nSCRIBE_API_TOKEN=abc\n") + assert (kept, generated) == ("A=1\nSCRIBE_API_TOKEN=abc\n", False) + + crlf, _ = ensure("SCRIBE_API_TOKEN=\r\nA=1\r\n") + assert "\n" not in crlf.replace("\r\n", "") and crlf.endswith("A=1\r\n") + + +def test_check_llm_never_reports_a_crash_as_not_configured(monkeypatch): + import json + check = _script("check_llm") + monkeypatch.setattr(check.llm, "_config", lambda: ("lambdatest", "https://gateway.example.com/v1/chat/completions", "k", "auto")) + monkeypatch.setattr(check, "gateway_health", lambda endpoint: True) + + def html_answer(*args, **kwargs): + raise json.JSONDecodeError("Expecting value", "", 0) + + monkeypatch.setattr(check.llm, "call", html_answer) + assert check.main() == 3 + + +def test_check_llm_classifies_by_status_not_by_message(monkeypatch): + check = _script("check_llm") + monkeypatch.setattr(check.llm, "_config", lambda: ("lambdatest", "https://gateway.example.com/v1/chat/completions", "k", "auto")) + monkeypatch.setattr(check, "gateway_health", lambda endpoint: True) + + def answering(status): + def call(*args, **kwargs): + error = check.llm.ModelError("model returned an error: bad key") + error.status = status + raise error + return call + + monkeypatch.setattr(check.llm, "call", answering(401)) + assert check.main() == 2 + monkeypatch.setattr(check.llm, "call", answering(200)) + assert check.main() == 3 + + +def test_gateway_health_probes_under_the_gateway_path_and_tolerates_a_missing_probe(monkeypatch): + import io + import urllib.error + check = _script("check_llm") + seen = [] + + def missing(url, timeout): + seen.append(url) + raise urllib.error.HTTPError(url, 404, "Not Found", {}, io.BytesIO(b"")) + + monkeypatch.setenv("GATEWAY_URL", "https://proxy.example.com/llm-gateway/") + monkeypatch.setattr(check.urllib.request, "urlopen", missing) + assert check.gateway_health() is True + assert seen == ["https://proxy.example.com/llm-gateway/healthz"] From ba9646ac19ea045422e0ab62e423bee6ed5538c4 Mon Sep 17 00:00:00 2001 From: Aman Kumar Sah Date: Thu, 29 Oct 2026 18:07:32 +0530 Subject: [PATCH 3/4] Fix the incident-scribe gateway review findings Check_llm no longer lets an unexpected answer exit 1 (the "not configured" code): errors carry the HTTP status, a bad GATEWAY_URL is caught as configuration, and any other failure exits 3. The gateway must be https (or localhost) because the access key goes with every call, the health probe keeps the URL's path, and auto-detect no longer takes over from an OpenAI chat user. Setup now repairs the token through scripts/ensure_token.py, which follows the loader's first-assignment-wins rule, creates .env as 0600, avoids tokens that start with a dash, passes the chosen Python to preflight, reports a failing `npm run setup`, checks Node 22.13 for the node:sqlite samples, and gives a hint that fits the provider in .env. Co-Authored-By: Claude Sonnet 5.5 --- samples/incident-scribe/docs/invoking.md | 5 +- .../incident-scribe/incident_scribe/llm.py | 18 +++++-- samples/incident-scribe/scripts/check_llm.py | 37 +++++++------- .../incident-scribe/scripts/ensure_token.py | 51 +++++++++++++++++++ samples/incident-scribe/scripts/preflight.sh | 7 +-- .../incident-scribe/test_incident_scribe.py | 6 ++- samples/setup.ps1 | 25 +++++---- samples/setup.sh | 35 +++++++------ 8 files changed, 130 insertions(+), 54 deletions(-) create mode 100644 samples/incident-scribe/scripts/ensure_token.py diff --git a/samples/incident-scribe/docs/invoking.md b/samples/incident-scribe/docs/invoking.md index 8d7677e..2f2fa61 100644 --- a/samples/incident-scribe/docs/invoking.md +++ b/samples/incident-scribe/docs/invoking.md @@ -82,8 +82,9 @@ Other supported providers use the same multi-turn agent loop: | `lambdatest` / `lt` | `GATEWAY_URL`, `LT_USERNAME`, `LT_ACCESS_KEY`; optional `LT_MODEL` (default `auto`) | If `LLM_PROVIDER` is omitted, the adapter selects Anthropic/GLM, Gemini, -Responses, or Chat Completions from the variables that are present, in that -order. +Responses, LambdaTest (when `GATEWAY_URL`, `LT_USERNAME` and `LT_ACCESS_KEY` are +all set and no `OPENAI_API_KEY` is), or Chat Completions from the variables that +are present, in that order. **That is the whole list.** The desk's model endpoint, key and deployment are its OWN configuration — it reads them from its `.env` and they are nobody diff --git a/samples/incident-scribe/incident_scribe/llm.py b/samples/incident-scribe/incident_scribe/llm.py index 4fdae90..3f35ea9 100644 --- a/samples/incident-scribe/incident_scribe/llm.py +++ b/samples/incident-scribe/incident_scribe/llm.py @@ -7,12 +7,17 @@ import os import time import urllib.error +import urllib.parse import urllib.request from typing import Any class ModelError(RuntimeError): - """The model could not be reached, or refused.""" + """The model could not be reached, or refused. `status` is the HTTP status when an endpoint answered.""" + + def __init__(self, message: str, status: int | None = None) -> None: + super().__init__(message) + self.status = status ANTHROPIC = {"anthropic", "glm", "zai"} @@ -31,7 +36,9 @@ def _config() -> tuple[str, str, str, str]: requested = "gemini" elif os.environ.get("OPENAI_ENDPOINT", "").strip(): requested = "responses" - elif all(os.environ.get(name, "").strip() for name in ("GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY")): + elif all(os.environ.get(name, "").strip() for name in ("GATEWAY_URL", "LT_USERNAME", "LT_ACCESS_KEY")) and not any( + os.environ.get(name, "").strip() for name in ("OPENAI_API_KEY", "OPENAI_INDIA_API_KEY") + ): requested = "lambdatest" else: requested = "chat" @@ -68,6 +75,9 @@ def _config() -> tuple[str, str, str, str]: ) if not value] if missing: raise ModelError("the LambdaTest gateway is not configured: " + ", ".join(missing)) + parts = urllib.parse.urlsplit(endpoint) + if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")): + raise ModelError("GATEWAY_URL must start with https:// (the account access key is sent with every call)") return "lambdatest", _chat_url(endpoint), base64.b64encode(f"{username}:{access_key}".encode()).decode(), model if requested in CHAT: @@ -258,11 +268,11 @@ def _request(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[st with urllib.request.urlopen(req, timeout=_timeout_seconds()) as response: data = json.load(response) if data.get("error"): - raise ModelError(f"model returned an error: {data['error']}") + raise ModelError(f"model returned an error: {data['error']}", status=response.status) return data except urllib.error.HTTPError as error: detail = error.read()[:400].decode("utf-8", "replace") - raise ModelError(f"model answered {error.code}: {detail}") from None + raise ModelError(f"model answered {error.code}: {detail}", status=error.code) from None except (urllib.error.URLError, TimeoutError, OSError) as error: if attempt >= retries: raise ModelError(f"could not reach the model: {error}") from None diff --git a/samples/incident-scribe/scripts/check_llm.py b/samples/incident-scribe/scripts/check_llm.py index a2d9b65..6222753 100644 --- a/samples/incident-scribe/scripts/check_llm.py +++ b/samples/incident-scribe/scripts/check_llm.py @@ -12,11 +12,9 @@ from __future__ import annotations import os -import re import sys import time import urllib.error -import urllib.parse import urllib.request from pathlib import Path @@ -53,15 +51,20 @@ def info(message: str) -> None: print(f" {message}") -def gateway_health(endpoint: str) -> bool: - """GET /healthz — unauthenticated, so it isolates "is it up" from "are my keys good".""" - parts = urllib.parse.urlsplit(endpoint) - url = f"{parts.scheme}://{parts.netloc}/healthz" +def gateway_health() -> bool: + """GET /healthz — unauthenticated, so it isolates "is it up" from "are my keys good". + + A gateway with no such route (404) is not a failure; the credential check below still decides. + """ + url = os.environ["GATEWAY_URL"].strip().rstrip("/") + "/healthz" try: with urllib.request.urlopen(url, timeout=15) as response: ok(f"health: {url} answered {response.status}") return True except urllib.error.HTTPError as error: + if error.code == 404: + info(f"health: {url} has no health route, relying on the credential check") + return True bad(f"health: {url} answered {error.code}") except (urllib.error.URLError, TimeoutError, OSError) as error: bad(f"health: {url} unreachable — {error}") @@ -86,7 +89,7 @@ def main() -> int: # 2. health. The gateway has a dedicated probe; other providers are judged # by the request below, since a 401 from them already proves they are up. probed = provider == "lambdatest" - if probed and not gateway_health(endpoint): + if probed and not gateway_health(): return 3 # 3. one tiny real request. Any HTTP answer, even a refusal, proves the @@ -97,18 +100,18 @@ def main() -> int: llm.call([llm.user("Reply with the single word: ok")], max_output_tokens=64) except llm.ModelError as error: message = str(error) - answered = re.search(r"model answered (\d{3})", message) - if answered and answered.group(1) in {"401", "403"}: - if not probed: - ok(f"health: endpoint is up ({answered.group(1)} answered)") + if error.status is None: + bad(f"health: endpoint unreachable — {message[:300]}") + return 3 + if not probed: + ok(f"health: endpoint is up ({error.status} answered)") + if error.status in (401, 403): bad(f"auth: credentials were refused — check the key/username in .env ({message[:200]})") return 2 - if answered: - if not probed: - ok(f"health: endpoint is up ({answered.group(1)} answered)") - bad(f"auth: could not be confirmed, the request was rejected — {message[:300]}") - return 3 - bad(f"health: endpoint unreachable — {message[:300]}") + bad(f"auth: could not be confirmed, the request was rejected — {message[:300]}") + return 3 + except Exception as error: # an unexpected answer must never read as "not configured" (exit 1) + bad(f"the endpoint answered something unexpected — {type(error).__name__}: {str(error)[:300]}") return 3 seconds = time.monotonic() - started if not probed: diff --git a/samples/incident-scribe/scripts/ensure_token.py b/samples/incident-scribe/scripts/ensure_token.py new file mode 100644 index 0000000..bfdd5da --- /dev/null +++ b/samples/incident-scribe/scripts/ensure_token.py @@ -0,0 +1,51 @@ +"""Make sure .env holds a usable SCRIBE_API_TOKEN, without touching anything else. + + python scripts/ensure_token.py [path-to-.env] # default: ./.env + +Fills the first active SCRIBE_API_TOKEN line when it is empty, and appends one when +there is none (a missing line, or only a commented-out one). A token that is set is +left alone. Prints "generated" or "kept". Hex, because a token that starts with "-" +is parsed as an option by `--token "$SCRIBE_API_TOKEN"`. +""" + +from __future__ import annotations + +import secrets +import sys +from pathlib import Path + + +def ensure(text: str) -> tuple[str, bool]: + """Same reading as the desk's .env loader: first assignment wins, quotes are stripped.""" + lines = text.splitlines(keepends=True) + for index, raw in enumerate(lines): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip() != "SCRIBE_API_TOKEN": + continue + if value.strip().strip('"').strip("'"): + return text, False + ending = raw[len(raw.rstrip("\r\n")):] + lines[index] = f"SCRIBE_API_TOKEN={secrets.token_hex(24)}{ending}" + return "".join(lines), True + newline = "\r\n" if "\r\n" in text else "\n" + separator = "" if not text or text.endswith("\n") else newline + return f"{text}{separator}SCRIBE_API_TOKEN={secrets.token_hex(24)}{newline}", True + + +def main() -> int: + path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env") + with path.open(encoding="utf-8", newline="") as handle: + text = handle.read() + new, generated = ensure(text) + if generated: + with path.open("w", encoding="utf-8", newline="") as handle: + handle.write(new) + print("generated" if generated else "kept") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/incident-scribe/scripts/preflight.sh b/samples/incident-scribe/scripts/preflight.sh index ccfcd5f..a4e7031 100644 --- a/samples/incident-scribe/scripts/preflight.sh +++ b/samples/incident-scribe/scripts/preflight.sh @@ -14,6 +14,7 @@ set -euo pipefail cd "$(dirname "$0")/.." +PY="${PY:-python3}" green() { printf ' \033[32m%s\033[0m %s\n' "ok" "$1"; } red() { printf ' \033[31m%s\033[0m %s\n' "!!" "$1"; } @@ -31,7 +32,7 @@ fi green "the token is configured" # --- 2. the model configuration ------------------------------------------ -if python3 -c "from incident_scribe.__main__ import _load_dotenv; _load_dotenv(); from incident_scribe.llm import _config; _config()" 2>/dev/null; then +if "$PY" -c "from incident_scribe.__main__ import _load_dotenv; _load_dotenv(); from incident_scribe.llm import _config; _config()" 2>/dev/null; then green "the model provider is configured" else red "the model provider is not configured" @@ -42,7 +43,7 @@ fi # # Checked rather than assumed: the token gate is the one behaviour a demo # cannot show going wrong on purpose, so it is worth knowing it is on. -REFUSED=$(python3 -m incident_scribe logs --session-id _preflight --token wrong-token 2>/dev/null || true) +REFUSED=$("$PY" -m incident_scribe logs --session-id _preflight --token wrong-token 2>/dev/null || true) if printf '%s' "$REFUSED" | grep -q '"unauthorized"'; then green "the token gate is on (a wrong token is refused)" else @@ -78,7 +79,7 @@ else fi # --- 6. the desk can actually reach it ------------------------------------ -if python3 -c " +if "$PY" -c " from incident_scribe.mcp import directory r = directory().call('lookup_service', {'name': 'checkout-api'}) raise SystemExit(0 if r.get('owning_team') == 'payments' else 1) diff --git a/samples/incident-scribe/test_incident_scribe.py b/samples/incident-scribe/test_incident_scribe.py index a2a90bc..11acbd9 100644 --- a/samples/incident-scribe/test_incident_scribe.py +++ b/samples/incident-scribe/test_incident_scribe.py @@ -363,6 +363,8 @@ def refused(req, timeout): raise AssertionError("expected ModelError") class Answer(io.BytesIO): + status = 200 + def __enter__(self): return self @@ -407,7 +409,7 @@ def test_check_llm_never_reports_a_crash_as_not_configured(monkeypatch): import json check = _script("check_llm") monkeypatch.setattr(check.llm, "_config", lambda: ("lambdatest", "https://gateway.example.com/v1/chat/completions", "k", "auto")) - monkeypatch.setattr(check, "gateway_health", lambda endpoint: True) + monkeypatch.setattr(check, "gateway_health", lambda: True) def html_answer(*args, **kwargs): raise json.JSONDecodeError("Expecting value", "", 0) @@ -419,7 +421,7 @@ def html_answer(*args, **kwargs): def test_check_llm_classifies_by_status_not_by_message(monkeypatch): check = _script("check_llm") monkeypatch.setattr(check.llm, "_config", lambda: ("lambdatest", "https://gateway.example.com/v1/chat/completions", "k", "auto")) - monkeypatch.setattr(check, "gateway_health", lambda endpoint: True) + monkeypatch.setattr(check, "gateway_health", lambda: True) def answering(status): def call(*args, **kwargs): diff --git a/samples/setup.ps1 b/samples/setup.ps1 index ebf32e2..750a0c1 100644 --- a/samples/setup.ps1 +++ b/samples/setup.ps1 @@ -50,6 +50,7 @@ Head "sample agents - setup" if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Bad "node is not on PATH (https://nodejs.org)"; exit 1 } $nodeMajor = [int](node -p "process.versions.node.split('.')[0]") +$nodeMinor = [int](node -p "process.versions.node.split('.')[1]") Ok "node $(node --version)" function Setup-IncidentScribe { @@ -66,12 +67,7 @@ function Setup-IncidentScribe { Ok "kept existing .env" } - $envText = Get-Content .env -Raw - if ($envText -match '(?m)^SCRIBE_API_TOKEN=[ \t]*\r?$') { - $token = (& $python -c "import secrets; print(secrets.token_urlsafe(24))").Trim() - $envText = [regex]::Replace($envText, '(?m)^SCRIBE_API_TOKEN=[ \t]*(?=\r?$)', "SCRIBE_API_TOKEN=$token", 1) - # no BOM: the Python reader would otherwise see it as part of the first key - [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.env'), $envText, (New-Object System.Text.UTF8Encoding($false))) + if ((& $python scripts\ensure_token.py .env) -eq 'generated') { Ok "generated SCRIBE_API_TOKEN in .env" } else { Ok "SCRIBE_API_TOKEN already set" @@ -95,9 +91,8 @@ function Setup-IncidentScribe { if ($LASTEXITCODE -ne 0) { $script:failed += 'incident-scribe' } } 1 { - Info "next: open incident-scribe\.env and fill in LT_USERNAME and LT_ACCESS_KEY" - Info " (GATEWAY_URL is already https://rook-samples.lambdatest.com; to use GLM or Gemini instead, swap the provider block)." - Info " then re-run setup.ps1 incident-scribe" + Info "next: complete the provider settings named above in incident-scribe\.env, then re-run setup.ps1 incident-scribe" + Info " (the LambdaTest template needs LT_USERNAME and LT_ACCESS_KEY; an older .env keeps its own LLM_PROVIDER)." } default { Bad "the model provider is configured but did not pass - fix .env and re-run"; $script:failed += 'incident-scribe' } } @@ -115,6 +110,7 @@ function Setup-IndustryAgents { if ($LASTEXITCODE -ne 0) { Bad "npm install failed"; $script:failed += 'industry-agents'; return } Ok "dependencies installed" npm run setup --silent | ForEach-Object { Info $_ } + if ($LASTEXITCODE -ne 0) { Bad "npm run setup failed"; $script:failed += 'industry-agents'; return } Info "next: add MODEL_API_KEY to the demo .env you want to run (demos\*\.env)" } finally { Pop-Location @@ -125,7 +121,16 @@ foreach ($sample in $Samples) { switch ($sample) { 'incident-scribe' { Setup-IncidentScribe } 'industry-agents' { Setup-IndustryAgents } - default { Head $sample; Ok "nothing to install" } + default { + Head $sample + # knowledge-vault and trip-weather-station import node:sqlite, unflagged from Node 22.13 + $needsSqlite = $sample -in 'knowledge-vault', 'trip-weather-station' + if ($needsSqlite -and ($nodeMajor -lt 22 -or ($nodeMajor -eq 22 -and $nodeMinor -lt 13))) { + Bad "needs Node 22.13+ for node:sqlite, found $(node --version)"; $failed += $sample + } else { + Ok "nothing to install" + } + } } } diff --git a/samples/setup.sh b/samples/setup.sh index 6d2660f..5f44ac9 100755 --- a/samples/setup.sh +++ b/samples/setup.sh @@ -58,8 +58,10 @@ for candidate in python3 python; do done NODE_MAJOR="" +NODE_MINOR="" if command -v node >/dev/null 2>&1; then NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" + NODE_MINOR="$(node -p 'process.versions.node.split(".")[1]')" fi head_ "sample agents — setup" @@ -74,21 +76,13 @@ setup_incident_scribe() { cd incident-scribe if [ ! -f .env ]; then - cp .env.example .env + (umask 077; cp .env.example .env) green "created .env from .env.example" else green "kept existing .env" fi - if grep -Eq '^SCRIBE_API_TOKEN=[[:space:]]*$' .env; then - local token - token="$($PY -c 'import secrets; print(secrets.token_urlsafe(24))')" - "$PY" - .env "$token" <<'EOF' -import re, sys -path, token = sys.argv[1:3] -text = open(path).read() -open(path, "w").write(re.sub(r"(?m)^SCRIBE_API_TOKEN=[ \t]*$", "SCRIBE_API_TOKEN=" + token, text, count=1)) -EOF + if [ "$("$PY" scripts/ensure_token.py .env)" = generated ]; then green "generated SCRIBE_API_TOKEN in .env" else green "SCRIBE_API_TOKEN already set" @@ -109,11 +103,10 @@ EOF local status=0 "$PY" scripts/check_llm.py || status=$? case "$status" in - 0) bash scripts/preflight.sh || FAILED="$FAILED incident-scribe" ;; + 0) PY="$PY" bash scripts/preflight.sh || FAILED="$FAILED incident-scribe" ;; 1) - info "next: open incident-scribe/.env and fill in LT_USERNAME and LT_ACCESS_KEY" - info " (GATEWAY_URL is already https://rook-samples.lambdatest.com; to use GLM or Gemini instead, swap the provider block)." - info " then re-run ./setup.sh incident-scribe" ;; + info "next: complete the provider settings named above in incident-scribe/.env, then re-run ./setup.sh incident-scribe" + info " (the LambdaTest template needs LT_USERNAME and LT_ACCESS_KEY; an older .env keeps its own LLM_PROVIDER)." ;; *) red "the model provider is configured but did not pass — fix .env and re-run"; FAILED="$FAILED incident-scribe" ;; esac cd .. @@ -129,14 +122,24 @@ setup_industry_agents() { else red "npm install failed"; FAILED="$FAILED industry-agents"; cd ..; return fi - npm run setup --silent | while IFS= read -r line; do info "$line"; done - info "next: add MODEL_API_KEY to the demo .env you want to run (demos/*/.env)" + if npm run setup --silent | while IFS= read -r line; do info "$line"; done; then + info "next: add MODEL_API_KEY to the demo .env you want to run (demos/*/.env)" + else + red "npm run setup failed"; FAILED="$FAILED industry-agents" + fi cd .. } # --- node-only samples ----------------------------------------------------- +# knowledge-vault and trip-weather-station import node:sqlite, unflagged from Node 22.13. setup_node_only() { head_ "$1" + case "$1" in + knowledge-vault|trip-weather-station) + if [ "$NODE_MAJOR" -lt 22 ] || { [ "$NODE_MAJOR" -eq 22 ] && [ "$NODE_MINOR" -lt 13 ]; }; then + red "needs Node 22.13+ for node:sqlite, found $(node --version)"; FAILED="$FAILED $1"; return + fi ;; + esac green "nothing to install" } From 6478340d1893199ae1be4b50bb9efea0b018a12e Mon Sep 17 00:00:00 2001 From: Aman Kumar Sah Date: Thu, 29 Oct 2026 18:42:04 +0530 Subject: [PATCH 4/4] Add .env loaders and a credentials prompt to incident-scribe setup scripts/env.sh and env.ps1 load .env into the current shell (source them), with the desk's rules: first assignment wins, the shell wins over the file. setup.sh and setup.ps1 now ask for LT_USERNAME and LT_ACCESS_KEY when they are empty, with the key typed hidden, and print the env line to run next. The docs now cover the loader and the https requirement. Co-Authored-By: Claude Sonnet 5.5 --- samples/incident-scribe/README.md | 15 +++- samples/incident-scribe/docs/invoking.md | 21 ++++- samples/incident-scribe/scripts/env.ps1 | 20 +++++ samples/incident-scribe/scripts/env.sh | 29 +++++++ .../scripts/prompt_credentials.py | 81 +++++++++++++++++++ .../incident-scribe/test_incident_scribe.py | 30 +++++++ samples/setup.ps1 | 3 + samples/setup.sh | 9 ++- 8 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 samples/incident-scribe/scripts/env.ps1 create mode 100644 samples/incident-scribe/scripts/env.sh create mode 100644 samples/incident-scribe/scripts/prompt_credentials.py diff --git a/samples/incident-scribe/README.md b/samples/incident-scribe/README.md index 4a9d139..5f70d27 100644 --- a/samples/incident-scribe/README.md +++ b/samples/incident-scribe/README.md @@ -231,7 +231,16 @@ export SCRIBE_API_TOKEN=choose-any-secret-string # macOS / Linux / WSL $env:SCRIBE_API_TOKEN = "choose-any-secret-string" # Windows PowerShell ``` -Use the same value in both places. +Use the same value in both places. Or load all of `.env` into the shell in one +step (source it, do not run it): + +```bash +. scripts/env.sh # macOS / Linux / WSL +``` + +```powershell +. .\scripts\env.ps1 # Windows PowerShell +``` ### Running it @@ -293,6 +302,10 @@ scripts/ preflight.sh pre-run checks (macOS / Linux / WSL) preflight.ps1 pre-run checks (Windows PowerShell) build-index.mjs enables semantic search (rebuilds the index) + check_llm.py keys present, endpoint up, credentials accepted + ensure_token.py fills SCRIBE_API_TOKEN in .env when it is empty + prompt_credentials.py asks for LT_USERNAME and LT_ACCESS_KEY when .env has none + env.sh / env.ps1 source them to load .env into your shell docs/ invoking.md the CLI contract incident-policy.pdf the incident policy (authoritative) diff --git a/samples/incident-scribe/docs/invoking.md b/samples/incident-scribe/docs/invoking.md index 2f2fa61..45bef2b 100644 --- a/samples/incident-scribe/docs/invoking.md +++ b/samples/incident-scribe/docs/invoking.md @@ -41,6 +41,19 @@ python -m incident_scribe logs --token $env:SCRIBE_API_TOKEN --session-id S-1 `.env` expands to an empty `--token`, which is refused as `unauthorized`. For a multi-line report, pipe a file: `Get-Content report.txt -Raw | python -m …`. +**Load `.env` into the shell first.** `./setup.sh` (or `setup.ps1`) creates +`.env` with a generated token, but a script cannot set variables in your shell, +so source the loader once per terminal. Variables already set in the shell win +over the file. + +```bash +. scripts/env.sh # macOS / Linux / WSL +``` + +```powershell +. .\scripts\env.ps1 # Windows PowerShell (note the leading dot) +``` + **There is no login step.** The token is issued out of band and lives in `SCRIBE_API_TOKEN`. Every one of the four commands takes `--token`, and it is checked before anything else happens — `logs` and `close` exactly as much as @@ -62,7 +75,8 @@ continues the same session. | --- | --- | | `SCRIBE_API_TOKEN` | the session credential, presented on every command | -The default provider remains the existing Responses API. To use GLM through +Pick a provider with `LLM_PROVIDER`; the `.env.example` template uses the +LambdaTest gateway. To use GLM through Z.AI's Anthropic-compatible endpoint, set `LLM_PROVIDER=glm`, `ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic`, `GLM_API_KEY`, and `GLM_MODEL` (for example `glm-5.3`). The adapter also accepts the names from @@ -81,6 +95,11 @@ Other supported providers use the same multi-turn agent loop: | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL`; optional `GEMINI_ENDPOINT` | | `lambdatest` / `lt` | `GATEWAY_URL`, `LT_USERNAME`, `LT_ACCESS_KEY`; optional `LT_MODEL` (default `auto`) | +`GATEWAY_URL` must start with `https://`, because the access key is sent with +every call. `./setup.sh` asks +for `LT_USERNAME` and `LT_ACCESS_KEY` when they are empty, with the key typed +hidden, and writes them to `.env`. + If `LLM_PROVIDER` is omitted, the adapter selects Anthropic/GLM, Gemini, Responses, LambdaTest (when `GATEWAY_URL`, `LT_USERNAME` and `LT_ACCESS_KEY` are all set and no `OPENAI_API_KEY` is), or Chat Completions from the variables that diff --git a/samples/incident-scribe/scripts/env.ps1 b/samples/incident-scribe/scripts/env.ps1 new file mode 100644 index 0000000..f2bfae7 --- /dev/null +++ b/samples/incident-scribe/scripts/env.ps1 @@ -0,0 +1,20 @@ +# Load .env into the current PowerShell session. Dot-source it, do not run it: +# +# . .\scripts\env.ps1 [path-to-.env] # default: .\.env +# +# Follows the desk's loader: the first assignment of a name wins, and a variable +# already set in the session wins over the file. + +param([string]$Path = '.env') + +if (-not (Test-Path $Path)) { Write-Error "env.ps1: no such file: $Path"; return } + +foreach ($line in [System.IO.File]::ReadAllLines((Resolve-Path $Path), [System.Text.UTF8Encoding]::new($false))) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) { continue } + $key, $value = $line -split '=', 2 + $key = ($key -replace '^export\s+', '').Trim() + if ($key -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue } + if (Test-Path "env:$key") { continue } + Set-Item -Path "env:$key" -Value $value.Trim().Trim('"').Trim("'") +} diff --git a/samples/incident-scribe/scripts/env.sh b/samples/incident-scribe/scripts/env.sh new file mode 100644 index 0000000..6dee8e3 --- /dev/null +++ b/samples/incident-scribe/scripts/env.sh @@ -0,0 +1,29 @@ +# Load .env into the current shell. Source it, do not run it: +# +# . scripts/env.sh [path-to-.env] # default: ./.env +# +# Follows the desk's loader: the first assignment of a name wins, and a variable +# already set in the shell wins over the file. + +_env_file="${1:-.env}" +if [ ! -f "$_env_file" ]; then + echo "env.sh: no such file: $_env_file" >&2 + unset _env_file + return 1 2>/dev/null || exit 1 +fi + +while IFS= read -r _line || [ -n "$_line" ]; do + _line="${_line%$'\r'}" + case "$_line" in ''|'#'*) continue ;; esac + case "$_line" in *=*) ;; *) continue ;; esac + _key="${_line%%=*}" + _val="${_line#*=}" + _key="${_key#export }" + _key="${_key// /}" + case "$_key" in ''|[0-9]*|*[!A-Za-z0-9_]*) continue ;; esac + _val="${_val#\"}"; _val="${_val%\"}"; _val="${_val#\'}"; _val="${_val%\'}" + printenv "$_key" >/dev/null 2>&1 && continue + export "$_key=$_val" +done < "$_env_file" + +unset _env_file _line _key _val diff --git a/samples/incident-scribe/scripts/prompt_credentials.py b/samples/incident-scribe/scripts/prompt_credentials.py new file mode 100644 index 0000000..6ec5a5c --- /dev/null +++ b/samples/incident-scribe/scripts/prompt_credentials.py @@ -0,0 +1,81 @@ +"""Ask for the LambdaTest username and access key when .env has none, and save them. + + python scripts/prompt_credentials.py [path-to-.env] # default: ./.env + +Asks only on a terminal, only for a LambdaTest setup, and only for a value that is +neither set in the shell nor filled in .env. The access key is typed hidden. An +empty answer skips that value. Nothing it reads is ever printed. +""" + +from __future__ import annotations + +import getpass +import os +import sys +from pathlib import Path + +PROMPTS = (("LT_USERNAME", False), ("LT_ACCESS_KEY", True)) + + +def first_assignment(text: str, key: str) -> tuple[int | None, str]: + """Same reading as the desk's .env loader: first assignment wins, quotes are stripped.""" + for index, raw in enumerate(text.splitlines()): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, _, value = line.partition("=") + if name.strip() == key: + return index, value.strip().strip('"').strip("'") + return None, "" + + +def set_value(text: str, key: str, value: str) -> str: + """Fill the first active line for `key`, or append one.""" + index, _ = first_assignment(text, key) + lines = text.splitlines(keepends=True) + if index is not None: + raw = lines[index] + lines[index] = f"{key}={value}{raw[len(raw.rstrip(chr(13) + chr(10))):]}" + return "".join(lines) + newline = "\r\n" if "\r\n" in text else "\n" + separator = "" if not text or text.endswith("\n") else newline + return f"{text}{separator}{key}={value}{newline}" + + +def wants_lambdatest(text: str) -> bool: + provider = (os.environ.get("LLM_PROVIDER") or first_assignment(text, "LLM_PROVIDER")[1]).strip().lower() + return provider in ("lambdatest", "lt") + + +def ask(key: str, hidden: bool) -> str: + if hidden: + return getpass.getpass(f" {key} (hidden): ").strip() + sys.stdout.write(f" {key}: ") + sys.stdout.flush() + return sys.stdin.readline().strip() + + +def main() -> int: + path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env") + with path.open(encoding="utf-8", newline="") as handle: + text = handle.read() + if not wants_lambdatest(text) or not sys.stdin.isatty(): + return 0 + + saved = [] + for key, hidden in PROMPTS: + if os.environ.get(key, "").strip() or first_assignment(text, key)[1]: + continue + answer = ask(key, hidden) + if answer and "\n" not in answer and "\r" not in answer: + text = set_value(text, key, answer) + saved.append(key) + if saved: + with path.open("w", encoding="utf-8", newline="") as handle: + handle.write(text) + print(f" ok saved {', '.join(saved)} to {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/incident-scribe/test_incident_scribe.py b/samples/incident-scribe/test_incident_scribe.py index 11acbd9..b0c5d6d 100644 --- a/samples/incident-scribe/test_incident_scribe.py +++ b/samples/incident-scribe/test_incident_scribe.py @@ -450,3 +450,33 @@ def missing(url, timeout): monkeypatch.setattr(check.urllib.request, "urlopen", missing) assert check.gateway_health() is True assert seen == ["https://proxy.example.com/llm-gateway/healthz"] + + +def test_prompt_credentials_fills_only_what_is_missing(tmp_path, monkeypatch): + prompt = _script("prompt_credentials") + template = "LLM_PROVIDER=lambdatest\nLT_USERNAME=\nLT_ACCESS_KEY=\nLT_MODEL=auto\n" + + filled = prompt.set_value(prompt.set_value(template, "LT_USERNAME", "me"), "LT_ACCESS_KEY", "s3cret") + assert filled == "LLM_PROVIDER=lambdatest\nLT_USERNAME=me\nLT_ACCESS_KEY=s3cret\nLT_MODEL=auto\n" + assert prompt.set_value("A=1", "LT_USERNAME", "me") == "A=1\nLT_USERNAME=me\n" + assert prompt.set_value("LT_USERNAME=\r\nA=1\r\n", "LT_USERNAME", "me") == "LT_USERNAME=me\r\nA=1\r\n" + + env_file = tmp_path / ".env" + env_file.write_text(template) + monkeypatch.delenv("LLM_PROVIDER", raising=False) + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(prompt.sys, "argv", ["prompt_credentials.py", str(env_file)]) + assert prompt.main() == 0 + assert env_file.read_text() == template + + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(prompt, "ask", lambda key, hidden: {"LT_USERNAME": "me", "LT_ACCESS_KEY": "s3cret"}[key]) + monkeypatch.setenv("LT_USERNAME", "from-the-shell") + assert prompt.main() == 0 + assert env_file.read_text() == "LLM_PROVIDER=lambdatest\nLT_USERNAME=\nLT_ACCESS_KEY=s3cret\nLT_MODEL=auto\n" + + glm = tmp_path / "glm.env" + glm.write_text("LLM_PROVIDER=glm\n") + monkeypatch.setattr(prompt.sys, "argv", ["prompt_credentials.py", str(glm)]) + assert prompt.main() == 0 + assert glm.read_text() == "LLM_PROVIDER=glm\n" diff --git a/samples/setup.ps1 b/samples/setup.ps1 index 750a0c1..8393eca 100644 --- a/samples/setup.ps1 +++ b/samples/setup.ps1 @@ -73,6 +73,8 @@ function Setup-IncidentScribe { Ok "SCRIBE_API_TOKEN already set" } + & $python scripts\prompt_credentials.py .env + if (-not $NoSemantic) { Info "installing embeddings (downloads a model, a few minutes)..." npm install --no-audit --no-fund *> $null @@ -89,6 +91,7 @@ function Setup-IncidentScribe { 0 { & powershell -NoProfile -ExecutionPolicy Bypass -File scripts\preflight.ps1 if ($LASTEXITCODE -ne 0) { $script:failed += 'incident-scribe' } + else { Info "next: cd incident-scribe; . .\scripts\env.ps1 (loads .env, including SCRIBE_API_TOKEN, into your session)" } } 1 { Info "next: complete the provider settings named above in incident-scribe\.env, then re-run setup.ps1 incident-scribe" diff --git a/samples/setup.sh b/samples/setup.sh index 5f44ac9..8b5e336 100755 --- a/samples/setup.sh +++ b/samples/setup.sh @@ -88,6 +88,8 @@ setup_incident_scribe() { green "SCRIBE_API_TOKEN already set" fi + "$PY" scripts/prompt_credentials.py .env + if [ "$SEMANTIC" = 1 ]; then info "installing embeddings (downloads a model, a few minutes)..." if npm install --no-audit --no-fund >/dev/null && node scripts/build-index.mjs >/dev/null; then @@ -103,7 +105,12 @@ setup_incident_scribe() { local status=0 "$PY" scripts/check_llm.py || status=$? case "$status" in - 0) PY="$PY" bash scripts/preflight.sh || FAILED="$FAILED incident-scribe" ;; + 0) + if PY="$PY" bash scripts/preflight.sh; then + info "next: cd incident-scribe && . scripts/env.sh (loads .env, including SCRIBE_API_TOKEN, into your shell)" + else + FAILED="$FAILED incident-scribe" + fi ;; 1) info "next: complete the provider settings named above in incident-scribe/.env, then re-run ./setup.sh incident-scribe" info " (the LambdaTest template needs LT_USERNAME and LT_ACCESS_KEY; an older .env keeps its own LLM_PROVIDER)." ;;