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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 14 additions & 5 deletions samples/incident-scribe/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <GATEWAY_URL>/v1/chat/completions and checks <GATEWAY_URL>/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
Expand Down
19 changes: 17 additions & 2 deletions samples/incident-scribe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -230,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

Expand Down Expand Up @@ -260,7 +270,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) |
Expand All @@ -273,6 +283,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 `<GATEWAY_URL>/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
Expand All @@ -291,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)
Expand Down
27 changes: 24 additions & 3 deletions samples/incident-scribe/docs/invoking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -79,10 +93,17 @@ 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`) |

`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, 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the code also skips LambdaTest when OPENAI_INDIA_API_KEY is set (llm.py line 40), so this could say "all set and neither OPENAI_API_KEY nor OPENAI_INDIA_API_KEY is".

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
Expand Down
38 changes: 33 additions & 5 deletions samples/incident-scribe/incident_scribe/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@

from __future__ import annotations

import base64
import json
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"}
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]:
Expand All @@ -29,6 +36,10 @@ 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")) and not any(
os.environ.get(name, "").strip() for name in ("OPENAI_API_KEY", "OPENAI_INDIA_API_KEY")
):
requested = "lambdatest"
else:
requested = "chat"

Expand All @@ -54,6 +65,21 @@ 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))
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These Basic credentials are the LambdaTest account username and access key, and they go to whatever GATEWAY_URL holds. With an http:// URL (a typo, or a local proxy), the account-wide key is sent in cleartext on every call. Refusing a non-https GATEWAY_URL unless the host is localhost would close that.


if requested in CHAT:
endpoint = os.environ.get("OPENAI_CHAT_ENDPOINT", "").strip()
if not endpoint:
Expand Down Expand Up @@ -242,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
Expand All @@ -271,11 +297,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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lambdatest provider goes through _call_chat, which always sends max_tokens. If LT_MODEL=auto routes to an OpenAI reasoning model (o-series or gpt-5 class) and the gateway passes the body through unchanged, every call fails with a 400 Unsupported parameter: 'max_tokens', because those models expect max_completion_tokens. If the gateway does translate the field, reasoning can use up the 64/1200/1500/2500-token caps and leave the content empty. This lines up with the token-budget item the description lists as unverified, so it's worth confirming what auto resolves to.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open on ba9646a: the lambdatest provider still goes through _call_chat with max_tokens. Whether this is a real problem depends on what LT_MODEL=auto routes to and whether the gateway translates the field. If the gateway owner confirms either one, this can be closed as not applicable.

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 []:
Expand Down Expand Up @@ -314,6 +340,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)

Expand Down
124 changes: 124 additions & 0 deletions samples/incident-scribe/scripts/check_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""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 sys
import time
import urllib.error
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() -> bool:
"""GET <GATEWAY_URL>/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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only ModelError, URLError, TimeoutError and OSError are caught, so any other exception ends in a traceback with Python's default exit code 1. Exit 1 is the "not configured" code, so setup prints the fill-in-your-keys hint, doesn't mark the sample failed, and still ends with "done." and exit 0.

Two ways to hit it:

  • GATEWAY_URL=rook-samples.lambdatest.com (no scheme): urlopen(':///healthz') raises ValueError: unknown url type.
  • A GATEWAY_URL that answers 200 with an HTML page: json.load in llm._request raises JSONDecodeError out of llm.call.

A final except Exception that prints the error and exits 3 (or a code of its own) would keep these out of the "not configured" path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly fixed in ba9646a. The new except Exception in main() covers the model request, but gateway_health() runs before it, and this urlopen still has only the HTTPError and URLError/TimeoutError/OSError handlers. Two GATEWAY_URL typos that pass the https check still escape (tested with plain urllib.request.urlopen):

  • https://rook-samples..lambdatest.com raises UnicodeEncodeError (idna: label empty)
  • a path with a space, e.g. https://host/llm gateway, raises http.client.InvalidURL

Both still end in a traceback with exit 1, so setup shows the "complete the provider settings" hint, doesn't mark incident-scribe failed, and finishes "done.". test_check_llm_never_reports_a_crash_as_not_configured stubs gateway_health, so it doesn't catch this. An except Exception as error: bad(...) after the two handlers, falling through to return False, would close it.

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}")
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():
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)
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
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:
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())
Loading
Loading