From 8b5794865ec77118cb24100b7a5d527a8b8f1356 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 20 Oct 2026 10:35:25 +1100 Subject: [PATCH 1/4] feat(zendesk): call the Claude API instead of the Claude Code CLI Every Zendesk Claude call (digest classification, transcripts, claude: notes) now goes through the Anthropic SDK with structured outputs, on the newest Opus resolved from the Models API. Authentication is ANTHROPIC_API_KEY from zendesk.env; the CLI, its login and the /home/zendesk bind are gone. Claude-Session: https://claude.ai/code/session_01Rq44MGw9nHuwYAjFYr8tGL --- deploy/README.md | 3 - deploy/env/zendesk.env.example | 7 +- deploy/zendesk-relay.service | 13 +- docs/jobs/zendesk-digest.md | 16 +- pyproject.toml | 1 + src/session_ops/jobs.toml | 10 +- src/session_ops/monitor/alert.py | 2 +- src/session_ops/ops/runner.py | 4 +- src/session_ops/zendesk/claude_api.py | 130 +++++++++++ src/session_ops/zendesk/claude_cli.py | 224 ------------------ src/session_ops/zendesk/note_reply.py | 10 +- src/session_ops/zendesk/triage.py | 31 +-- tests/goldens/units/dropins.txt | 4 - tests/monitor/test_alert.py | 59 ++--- tests/ops/fake_jobs.py | 10 +- tests/ops/test_runner.py | 10 +- tests/zendesk/test_note_reply.py | 21 +- tests/zendesk/test_triage.py | 321 ++++++++------------------ 18 files changed, 301 insertions(+), 575 deletions(-) create mode 100644 src/session_ops/zendesk/claude_api.py delete mode 100644 src/session_ops/zendesk/claude_cli.py diff --git a/deploy/README.md b/deploy/README.md index 3dfeb43..2ec337e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -41,9 +41,6 @@ Then fill the env files and run `install.sh` again. It enables each job once its files have content. ```bash -runuser -u zendesk -- env HOME=/home/zendesk sh -c 'curl -fsSL https://claude.ai/install.sh | bash' -runuser -u zendesk -- env HOME=/home/zendesk /home/zendesk/.local/bin/claude # then /login - cp /opt/session-ops/deploy/nginx-webhooks.conf /etc/nginx/sites-available/webhooks.session.codes ln -s /etc/nginx/sites-available/webhooks.session.codes /etc/nginx/sites-enabled/ nginx -t && certbot --nginx --redirect -d webhooks.session.codes && nginx -t && systemctl reload nginx diff --git a/deploy/env/zendesk.env.example b/deploy/env/zendesk.env.example index 2d3602b..e576e8d 100644 --- a/deploy/env/zendesk.env.example +++ b/deploy/env/zendesk.env.example @@ -2,11 +2,8 @@ # A `#` starts a comment only as a line's first character. Restart zendesk-relay after # an edit: it reads this file at start. -# Not HOME, which systemd sets from the account. PATH replaces systemd's default. -PATH=/home/zendesk/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -# Optional: a one-year token from `claude setup-token` on a machine with a browser, same -# subscription. Without it the interactive login expires within weeks. -#CLAUDE_CODE_OAUTH_TOKEN= +# Claude API key for the digest, the transcripts and the `claude:` notes. +ANTHROPIC_API_KEY= ZENDESK_SUBDOMAIN= # Authors every comment the reply flow posts. ZENDESK_EMAIL= diff --git a/deploy/zendesk-relay.service b/deploy/zendesk-relay.service index aba5e99..a0209d6 100644 --- a/deploy/zendesk-relay.service +++ b/deploy/zendesk-relay.service @@ -29,26 +29,17 @@ NoNewPrivileges=yes PrivateTmp=yes PrivateDevices=yes ProtectSystem=strict -# tmpfs rather than yes: the Claude Code CLI the translation shells out to reads its -# login from $HOME, and `yes` makes /home inaccessible. tmpfs masks /home with an -# empty one and BindPaths mounts just this account's back in, so every other home on -# the box stays hidden. The bind is the real on-disk directory — the tmpfs is a mask -# for this service's mount namespace, not storage, so nothing here is lost on reboot. -ProtectHome=tmpfs -BindPaths=/home/zendesk +ProtectHome=yes ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes +# AF_UNIX for asyncio's self-pipe, a socketpair. RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictNamespaces=yes LockPersonality=yes MemoryDenyWriteExecute=yes SystemCallFilter=@system-service SystemCallErrorNumber=EPERM -# The relay itself still stores nothing. The writable path is for the Claude Code -# CLI, which keeps its credentials and cache under $HOME; without it the translation -# fails with a bare non-zero exit from `claude`. -ReadWritePaths=/home/zendesk ReadOnlyPaths=/opt/session-ops [Install] diff --git a/docs/jobs/zendesk-digest.md b/docs/jobs/zendesk-digest.md index 5a92327..76b52c1 100644 --- a/docs/jobs/zendesk-digest.md +++ b/docs/jobs/zendesk-digest.md @@ -5,7 +5,7 @@ Claude reviews the Zendesk tickets awaiting a reply — `new` and `open`, no app | | | | --- | --- | | Runs | `session-ops-queue.timer`, Mon–Fri from 10:00 Australia/Melbourne, after `github-prs-digest`: `zendesk-resolve-reviews --apply`, then `zendesk-triage` | -| Secrets | `/etc/session-ops/zendesk.env`: the Zendesk API token, the triage channel's webhook, and the Claude Code CLI login of the `zendesk` account | +| Secrets | `/etc/session-ops/zendesk.env`: the Zendesk API token, the triage channel's webhook, and the Claude API key | | Dry run | `session-ops run zendesk-digest --dry-run`; `uv run zendesk-triage --window-hours 72 --dry-run` from a checkout. On the host, through `systemd-run` as in [deploy/README.md](../../deploy/README.md). | | Re-run | `systemctl start session-ops@zendesk-digest.service` | | Logs | `journalctl -u session-ops@zendesk-digest -n 50 --no-pager` | @@ -49,7 +49,7 @@ The star-floor machinery (`partition_reviews`, `--review-star-floor`, `--include Twitter DM tickets arrive with `description` identical to `subject` — both just `"Conversation with "` — which is 15% of non-review tickets and unclassifiable as fetched. For those only, `hydrate_descriptions` fetches a page of up to 10 comments and joins every body that differs from the subject into the description; later replies often carry the actual detail. Hydration is an enrichment, so an HTTP error or an unreachable endpoint leaves the ticket as-is rather than failing the run (`--no-hydrate` to skip it entirely). -The script (`zendesk-triage`, [triage.py](../../src/session_ops/zendesk/triage.py)) fetches the tickets in a rolling time window, classifies the whole batch in one schema-enforced request through the Claude Code CLI, and posts a Discord digest: a short header, the new tickets not in English, one line per ticket worth looking into, then the abuse reports and the unflagged tickets. +The script (`zendesk-triage`, [triage.py](../../src/session_ops/zendesk/triage.py)) fetches the tickets in a rolling time window, classifies the whole batch in one schema-enforced request to the Claude API, and posts a Discord digest: a short header, the new tickets not in English, one line per ticket worth looking into, then the abuse reports and the unflagged tickets. Each line leads with a severity marker, a category emoji and a platform icon, links the ticket id, and carries the model's one-line summary plus its root-cause guess: @@ -115,9 +115,7 @@ Two caveats worth knowing: ## Claude Authentication -There is no Claude key. Both Claude calls — the digest's classification and the reply flow's translation — shell out to the locally installed Claude Code CLI (`claude --print`), which authenticates as whoever it is logged in as. On the host that is the service user; see [deploy/README.md](../../deploy/README.md). - -The trade is process startup, a few seconds per call, against holding an API credential on the box. That is invisible on a nightly digest, and on the reply dialog Discord keeps the interaction open while it runs. +Every Claude call — the digest's classification, the transcripts and the `claude:` notes — goes to the Claude API with `ANTHROPIC_API_KEY` from `/etc/session-ops/zendesk.env`. A refused key or an exhausted balance is named as such in the failure alert, since re-running fixes neither. ## Optional Configuration @@ -140,9 +138,9 @@ The trade is process startup, a few seconds per call, against holding an API cre **Opus**, because the hard part of this job isn't per-ticket classification — enum-constrained categories with prompt guidance is squarely mid-tier work. It's the two batch-wide fields: `cluster` has to spot that a German app-store review and an English bug report describe one root cause, and `priority_rank` has to stay consistent across the whole batch. Those need the model to hold ~45 heterogeneous tickets in mind at once. The exact-transcription requirement (a 66-character Session ID copied verbatim) points the same way. And the entire job costs **single-digit dollars a month** on any current model — roughly $10 on Opus 5 against $6 on Sonnet 5 and $2 on Haiku 4.5 — so trading classification quality for a few dollars would be optimising the wrong thing when the cost of a miss is an unseen security report or a crash cluster nobody grouped. -**The `opus` alias rather than a pinned id**, for every Zendesk call: the digest, the -transcripts and the `claude:` notes. The CLI resolves it to its newest Opus, so a new model -arrives with a CLI update and nothing here needs bumping. The cost: the digest's clusters, +**The newest Opus rather than a pinned id**, for every Zendesk call: the digest, the +transcripts and the `claude:` notes. Each run looks it up in the Models API, so a new model +is picked up with nothing here to bump. The cost: the digest's clusters, priority ranks and severity calibration can shift when that happens, with no run in between to compare. There is no override; one model is all this job supports. @@ -196,7 +194,7 @@ Offline tests covering the window arithmetic, dedup partitioning, state round-tr ## Local Testing -Local runs need the `claude` CLI on `PATH` and logged in (`claude --version`), alongside the Zendesk credentials. `--dry-run` prints the Discord payload instead of posting, so no webhook is needed. Keep it to local runs: it prints ticket content. `--no-discord` prints counts only: +Local runs need `ANTHROPIC_API_KEY` alongside the Zendesk credentials. `--dry-run` prints the Discord payload instead of posting, so no webhook is needed. Keep it to local runs: it prints ticket content. `--no-discord` prints counts only: ``` uv sync diff --git a/pyproject.toml b/pyproject.toml index 88f3640..e92211c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Session Foundation's scheduled jobs and webhooks: Zendesk, GitHub readme = "README.md" requires-python = ">=3.11" dependencies = [ + "anthropic>=1.15", # CLDR data: a new babel can rename a language in the generated language lists. "babel==2.17.0", "colorama>=0.4.6", diff --git a/src/session_ops/jobs.toml b/src/session_ops/jobs.toml index 916a280..931a7d4 100644 --- a/src/session_ops/jobs.toml +++ b/src/session_ops/jobs.toml @@ -46,18 +46,12 @@ entry = "session_ops.zendesk.digest_job:main" args = ["--state", "{state}/seen.json"] user = "zendesk" env_files = ["/etc/session-ops/zendesk.env"] -env = ["ZENDESK_SUBDOMAIN", "ZENDESK_EMAIL", "ZENDESK_API_TOKEN", "ZENDESK_DISCORD_WEBHOOK_URL"] +env = ["ZENDESK_SUBDOMAIN", "ZENDESK_EMAIL", "ZENDESK_API_TOKEN", "ZENDESK_DISCORD_WEBHOOK_URL", + "ANTHROPIC_API_KEY"] channel_env = "ZENDESK_DISCORD_WEBHOOK_URL" max_age_hours = 80 # A full 1000-ticket resolve is ten bulk jobs polled for up to 300 s each. timeout = "90min" -# The Claude Code CLI reads its login from $HOME: mask /home and bind only this one. -unit = [ - "ProtectHome=tmpfs", - "BindPaths=/home/zendesk", - "ReadWritePaths=/home/zendesk", - "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX", -] [[job]] name = "crowdin-duplicates" diff --git a/src/session_ops/monitor/alert.py b/src/session_ops/monitor/alert.py index 7853ec9..e8c2317 100644 --- a/src/session_ops/monitor/alert.py +++ b/src/session_ops/monitor/alert.py @@ -32,7 +32,7 @@ from session_ops.ops.runner import scrub from session_ops.shared import http from session_ops.shared.discord import post_to_discord -from session_ops.zendesk.claude_cli import advice +from session_ops.zendesk.claude_api import advice EXCERPT_CHARS = 400 diff --git a/src/session_ops/ops/runner.py b/src/session_ops/ops/runner.py index 7724860..b727c0a 100644 --- a/src/session_ops/ops/runner.py +++ b/src/session_ops/ops/runner.py @@ -34,7 +34,7 @@ from session_ops.ops import registry from session_ops.shared import discord, http from session_ops.shared.state import write_json -from session_ops.zendesk import claude_cli +from session_ops.zendesk import claude_api ALERTED_MARKER = "alerted" LAST_ALERT = "last-alert.json" @@ -125,7 +125,7 @@ def alert_message(job, host, current_step, error, outcome): mark = "✅" if not result else ("⚠️" if name in outcome.optional else "❌") lines.append(f"{mark} **{name}**" + (f": {result}" if result else "")) journal = f"`journalctl -u session-ops@{job.name} -n 50 --no-pager`" - account = claude_cli.advice(error, *outcome.targets.values()) + account = claude_api.advice(error, *outcome.targets.values()) if account: lines += [*account, journal] else: diff --git a/src/session_ops/zendesk/claude_api.py b/src/session_ops/zendesk/claude_api.py new file mode 100644 index 0000000..5edd2d8 --- /dev/null +++ b/src/session_ops/zendesk/claude_api.py @@ -0,0 +1,130 @@ +"""The Claude API as the Zendesk jobs call it: one schema-enforced request, +the model, and a failure worth quoting. +""" +import functools +import json +import sys + +import anthropic + + +# Every Zendesk call uses the newest Opus, resolved once per process from the Models +# API, so a new Opus is picked up with no change here; the digest's clusters and +# severity calibration can shift with it. +OPUS_PREFIX = "claude-opus-" + +# The model's ceiling: a full digest chunk's findings and its thinking share it. Only a +# streamed request may ask for this much; the SDK refuses a non-streaming one. +MAX_OUTPUT_TOKENS = 128000 + +# The API's error messages do not echo the prompt, but this log must never carry ticket +# text, so nothing here is passed through whole. +FAILURE_CHARS = 300 + +API_PREFIX = "Claude API" + + +@functools.cache +def client(): + """Authenticates with ANTHROPIC_API_KEY from the environment.""" + return anthropic.Anthropic() + + +@functools.cache +def model(): + """The id of the newest Opus the key can use.""" + try: + opus = [m for m in client().models.list() if m.id.startswith(OPUS_PREFIX)] + except anthropic.APIError as exc: + sys.exit(failure(exc, "the model lookup")) + if not opus: + sys.exit(f"{API_PREFIX} lists no {OPUS_PREFIX}* model for this key.") + return max(opus, key=lambda m: m.created_at).id + + +def failure(exc, label): + """The log line for a failed request. advice() matches on its wording.""" + message = (getattr(exc, "message", None) or str(exc)).strip()[:FAILURE_CHARS] + if isinstance(exc, anthropic.APITimeoutError): + return f"{API_PREFIX} did not finish {label} in time." + if isinstance(exc, (anthropic.AuthenticationError, anthropic.PermissionDeniedError)): + return f"{API_PREFIX} refused the key on {label}: {message}" + if isinstance(exc, anthropic.APIStatusError): + return f"{API_PREFIX} returned {exc.status_code} on {label}: {message}" + return f"{API_PREFIX} was unreachable on {label}: {message}" + + +def claude_json(model, effort, system_prompt, schema, prompt, timeout, label): + """Run one schema-enforced Claude request. Returns the parsed payload. + + Shared by the digest's classification and note_reply.py's composing: same request + shape, same error semantics, one place to keep them right. Every failure exits + with a line starting API_PREFIX, which is what advice() reads. + """ + try: + with client().with_options(timeout=timeout).messages.stream( + model=model, + max_tokens=MAX_OUTPUT_TOKENS, + system=system_prompt, + messages=[{"role": "user", "content": prompt}], + output_config={ + "effort": effort, + "format": {"type": "json_schema", "schema": schema}, + }, + ) as stream: + response = stream.get_final_message() + except anthropic.APIError as exc: + sys.exit(failure(exc, label)) + + if response.stop_reason == "refusal": + sys.exit(f"{API_PREFIX} refused {label} " + f"({getattr(response.stop_details, 'category', None)!r}).") + # A truncated answer is JSON that stops mid-object; the parse below would report a + # baffling syntax error for something whose only fix is a smaller batch. + if response.stop_reason == "max_tokens": + sys.exit(f"{API_PREFIX} ran out of output tokens on {label}, so the JSON is " + f"incomplete. Ask for less per call; for the digest that is a lower " + f"--batch-size.") + raw = "".join(block.text for block in response.content if block.type == "text") + if not raw: + sys.exit(f"{API_PREFIX} returned no text on {label}.") + try: + return json.loads(raw) + except ValueError as exc: + sys.exit(f"{API_PREFIX} result on {label} is not the JSON the schema asked " + f"for ({exc}).") + + +# A dead key reads as a broken job unless the alert names it: the job is fine and +# re-running it fixes nothing. Only a line this module wrote is checked: Zendesk's own +# 401 body says "Couldn't authenticate you". +AUTH_SIGNATURES = ("refused the key",) +# An account out of credit has a valid key, so rotating it fixes nothing either. +BILLING_SIGNATURES = ("credit balance",) +ENV_FILE = "/etc/session-ops/zendesk.env" + + +def _api_failure(line, signatures): + line = line or "" + return line.startswith(API_PREFIX) and any(s in line.lower() for s in signatures) + + +def is_auth_failure(line): + return _api_failure(line, AUTH_SIGNATURES) + + +def is_billing_failure(line): + return _api_failure(line, BILLING_SIGNATURES) + + +def advice(*lines): + """What an alert says instead of a re-run hint when the Claude account is the + problem, or [] when re-running might help.""" + if any(is_auth_failure(line) for line in lines): + return ["**The Claude API key was refused.** Re-running the unit will not fix " + f"it — put a valid `ANTHROPIC_API_KEY` in `{ENV_FILE}`, then restart " + "zendesk-relay."] + if any(is_billing_failure(line) for line in lines): + return ["**The Claude API account is out of credit.** Re-running the unit will " + "not fix it until the account is topped up."] + return [] diff --git a/src/session_ops/zendesk/claude_cli.py b/src/session_ops/zendesk/claude_cli.py deleted file mode 100644 index 2c13e8f..0000000 --- a/src/session_ops/zendesk/claude_cli.py +++ /dev/null @@ -1,224 +0,0 @@ -"""The Claude Code CLI as the Zendesk jobs call it: one schema-enforced request, -the model, and a failure worth quoting. -""" -import json -import os -import subprocess -import sys - - -# The CLI's alias for its newest Opus, the one model every Zendesk call uses. A new Opus -# arrives with a CLI update and nothing changes here; the digest's clusters and severity -# calibration can shift with it. -MODEL = "opus" - - -# The classifier is the local Claude Code CLI rather than the Anthropic SDK, so -# authentication is whatever `claude` is already logged in as and no key lives here. -CLAUDE_CLI = "claude" - - -# Dropped from the CLI's environment. Each one silently outranks whatever `claude` is -# logged in as, and each is API-backend configuration — a box that once ran that way -# still has the key in its EnvironmentFile, where it is now dead config that would -# otherwise pick the credential, and the billing, for every classification. -# -# CLAUDE_CODE_OAUTH_TOKEN is deliberately not in this list. It is a subscription -# credential like the interactive login, not an API key, and it is the only one of -# these an unattended host can renew on a yearly rather than weekly cadence — see -# deploy/env/zendesk.env.example. Nothing else can set it: it has never been written by anything -# this repo deploys, so it reaches the CLI only because somebody put it there. -CLAUDE_AUTH_OVERRIDES = ( - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", -) - - -# How much of a failed call's output reaches the log. The CLI can echo input back and -# this log must not carry ticket text, so nothing here is ever passed through whole. -CLI_FAILURE_CHARS = 300 - - - -def cli_failure_detail(stdout, stderr, limit=CLI_FAILURE_CHARS): - """The readable half of a failed `claude --print` run, clipped for the log. - - stderr wins, but the failures that matter most — a refused login, an exhausted - limit — leave it empty and put their message in the `--output-format json` - envelope on stdout, where it sits behind enough usage boilerplate to survive no - clip at all. Hence parsing the envelope rather than clipping it. `terminal_reason` - rides along when it fits: it is what separates an auth failure from a limit. - - Output that is not that envelope is reported raw: a CLI that dies before emitting - one has still said the only thing anybody will get. - """ - detail = (stderr or "").strip() - if detail: - return detail[:limit] - raw = (stdout or "").strip() - try: - envelope = json.loads(raw) - except ValueError: - envelope = None - if isinstance(envelope, dict): - message = "" - for name in ("result", "error"): - value = envelope.get(name) - if isinstance(value, dict): - value = value.get("message") - if isinstance(value, str) and value.strip(): - message = value.strip() - break - reason = envelope.get("terminal_reason") or envelope.get("subtype") - reason = reason.strip() if isinstance(reason, str) else "" - if message and reason and len(message) + len(reason) + 3 <= limit: - return f"{message} ({reason})" - if message: - return message[:limit] - if reason: - return f"it reported {reason!r} and no message." - return raw[:limit] - - -def claude_cli_json(model, effort, system_prompt, schema, prompt, timeout, label): - """Run one schema-enforced Claude Code request. Returns the parsed payload. - - Shared by the digest's classification and note_reply.py's composing: same flags, - same error semantics, one place to keep them right. - - `--json-schema` enforces the schema the way the API's structured outputs did. - Authentication is whatever `claude` is already logged in as, so neither caller - holds a Claude key. - - The prompt goes over **stdin**, not argv. Linux caps one argument at 128KB - (MAX_ARG_STRLEN) and a full --batch-size 400 chunk is around 685KB, so passing it - as an argument would work on a normal day and die with "Argument list too long" - on a backfill. It is also the more private channel: argv is world-readable - through /proc, and these prompts carry ticket text. - """ - command = [ - CLAUDE_CLI, "--print", - "--model", model, - "--effort", effort, - "--system-prompt", system_prompt, - "--json-schema", json.dumps(schema), - "--output-format", "json", - "--no-session-persistence", - # Nothing outside this call may change what the model is told. The two flags - # cover different halves of that and neither implies the other: - # --setting-sources "" drops the user and project settings — and the hooks - # inside them — while --tools "" removes the tools. Without the first, a - # .claude/settings.json next to this file, or one in the service account's - # home, silently joins every classification and every translation. - # - # --tools stays last: it is variadic, so it swallows any following argument - # that does not begin with a dash. - "--setting-sources", "", - "--tools", "", - ] - child_env = {name: value for name, value in os.environ.items() - if name not in CLAUDE_AUTH_OVERRIDES} - try: - done = subprocess.run(command, input=prompt, capture_output=True, text=True, - check=False, timeout=timeout, env=child_env) - except FileNotFoundError: - sys.exit(f"{CLAUDE_CLI} is not on PATH. {label} runs through the Claude Code " - f"CLI, so it has to be installed and logged in.") - except subprocess.TimeoutExpired: - sys.exit(f"{CLAUDE_CLI} did not finish {label} within {timeout}s.") - - if done.returncode != 0: - detail = cli_failure_detail(done.stdout, done.stderr) - if not detail: - detail = ("it printed nothing, which is what a login it can no longer " - "use looks like; check that `claude` is still signed in.") - sys.exit(f"{CLAUDE_CLI} exited {done.returncode} on {label}: {detail}") - try: - response = json.loads(done.stdout) - except ValueError as exc: - sys.exit(f"{CLAUDE_CLI} returned output that is not JSON on {label} ({exc}).") - if not isinstance(response, dict): - sys.exit(f"{CLAUDE_CLI} returned {type(response).__name__} on {label}, " - f"expected an object.") - # is_error and subtype are the CLI's signals for success; stop_reason deliberately - # is not — a successful structured-output run reports "tool_use", because that is - # how the schema is enforced underneath. - if response.get("is_error") or response.get("subtype") != "success": - detail = cli_failure_detail(done.stdout, done.stderr) - sys.exit(f"{CLAUDE_CLI} reported failure on {label} " - f"(subtype={response.get('subtype')!r}, " - f"api_error_status={response.get('api_error_status')!r})" - f"{': ' + detail if detail else '.'}") - # stop_reason is worth reading for this one value. There is no --max-tokens to - # raise, so an answer too long to finish comes back as JSON that stops mid-object, - # and the parse below would report a baffling syntax error for something whose - # only fix is a smaller batch. - if response.get("stop_reason") == "max_tokens": - sys.exit(f"{CLAUDE_CLI} ran out of output tokens on {label}, so the JSON is " - f"incomplete. Ask for less per call; for the digest that is a lower " - f"--batch-size.") - - # structured_output is the object --json-schema produced, so it beats re-parsing - # the `result` string: one less decode, and immune to prose alongside the JSON. - payload = response.get("structured_output") - if payload is not None: - return payload - raw = response.get("result") - if not raw: - sys.exit(f"{CLAUDE_CLI} returned neither structured_output nor a result " - f"on {label}.") - try: - return json.loads(raw) - except ValueError as exc: - sys.exit(f"{CLAUDE_CLI} result on {label} is not the JSON the schema asked " - f"for ({exc}).") - - -# A dead login reads as a broken job unless the alert names it: the job is fine and -# re-running it fixes nothing. The CLI's wording varies between refusals, so this -# matches the words that survive the rewordings. Only a line the CLI path wrote is -# checked, from either way a call fails: Zendesk's own 401 body says "Couldn't -# authenticate you". -AUTH_SIGNATURES = ("oauth", "/login", "authenticate", "invalid api key", - "unauthorized", "signed in") -# An account out of credit is logged in, so logging in again fixes nothing either. -BILLING_SIGNATURES = ("credit balance",) -CLI_FAILURE_PREFIXES = (f"{CLAUDE_CLI} exited", f"{CLAUDE_CLI} reported failure") -# Where the Claude Code CLI lives for the account the units run as; see -# deploy/README.md. Spelled out because an alert that says "log in again" without -# saying how sends whoever is on call to the README first. -RELOGIN = ("runuser -u zendesk -- env HOME=/home/zendesk " - "/home/zendesk/.local/bin/claude # then /login") - - -def _cli_failure(line, signatures): - line = (line or "").lower() - return (line.startswith(CLI_FAILURE_PREFIXES) - and any(signature in line for signature in signatures)) - - -def is_auth_failure(line): - return _cli_failure(line, AUTH_SIGNATURES) - - -def is_billing_failure(line): - return _cli_failure(line, BILLING_SIGNATURES) - - -def relogin_advice(): - """The lines an alert carries for a dead login, in place of any re-run hint.""" - return ["**The Claude Code CLI is no longer logged in.** Re-running the " - "unit will not fix it — log in again as the service account:", - f"```\n{RELOGIN}\n```"] - - -def advice(*lines): - """What an alert says instead of a re-run hint when the CLI's account is the - problem, or [] when re-running might help.""" - if any(is_auth_failure(line) for line in lines): - return relogin_advice() - if any(is_billing_failure(line) for line in lines): - return ["**The Claude account the CLI uses is out of credit.** Re-running the " - "unit will not fix it until the account is topped up."] - return [] diff --git a/src/session_ops/zendesk/note_reply.py b/src/session_ops/zendesk/note_reply.py index 14c2748..8d7b042 100644 --- a/src/session_ops/zendesk/note_reply.py +++ b/src/session_ops/zendesk/note_reply.py @@ -67,7 +67,7 @@ from session_ops.shared.discord import clip from session_ops.shared.env import get_env from session_ops.shared.text import undash_english -from session_ops.zendesk import api, claude_cli, transcript +from session_ops.zendesk import api, claude_api, transcript COMPOSE_TIMEOUT_SECONDS = 240 PLACEMENT_TIMEOUT_SECONDS = 120 @@ -442,7 +442,7 @@ def place_ticket(model, book, ticket, sample): catalogue = "\n".join(f"- {g['key']}: {g['title']}" for g in book["groups"]) body = clip(sample, CUSTOMER_SAMPLE_CHARS) try: - found = claude_cli.claude_cli_json( + found = claude_api.claude_json( model, "medium", PLACEMENT_SYSTEM, PLACEMENT_SCHEMA, f"GROUP CATALOGUE:\n{catalogue}\n\nTHE TICKET:\n" f"{(ticket.get('subject') or '')[:200]}\n\n{body}", @@ -705,7 +705,7 @@ def validate_composition(result): def compose(model, sample, brief, previous=None, precedent=None, thread=None): - return validate_composition(claude_cli.claude_cli_json( + return validate_composition(claude_api.claude_json( model, "medium", COMPOSE_SYSTEM, COMPOSE_SCHEMA, build_compose_prompt(sample, brief, previous, precedent, thread), COMPOSE_TIMEOUT_SECONDS, "the reply draft")) @@ -1160,7 +1160,7 @@ def translate_conversation(session, subdomain, model, ticket, comments): return "empty", None turns, omitted = fit_turns(turns, transcript.TRANSCRIPT_INPUT_CHARS) try: - rendered = claude_cli.claude_cli_json( + rendered = claude_api.claude_json( model, "medium", transcript.TRANSCRIPT_SYSTEM_PROMPT, transcript.TRANSCRIPT_SCHEMA, transcript_payload(turns), transcript.ENGLISH_TIMEOUT_SECONDS, f"the English transcript of #{ticket_id}") @@ -1287,7 +1287,7 @@ def main(argv=None): parser.add_argument("--dry-run", action="store_true", help="do everything except write to Zendesk") args = parser.parse_args(argv) - model = claude_cli.MODEL + model = claude_api.model() subdomain = get_env("ZENDESK_SUBDOMAIN") session = api.zendesk_session(get_env("ZENDESK_EMAIL"), diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index 1a0b41e..d623fe6 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -22,23 +22,15 @@ bug_report | low_star_review | legal_request | security_or_legislation | question | feature_request | other -Classification is driven by the local Claude Code CLI (`claude --print`), with ---json-schema enforcing SCHEMA exactly as the API's structured outputs did. ---findings skips it entirely and renders findings produced elsewhere. +Classification goes through the Claude API on the newest Opus, with structured +outputs enforcing SCHEMA. --findings skips it entirely and renders findings produced +elsewhere. Config (env vars, or flags for local runs): ZENDESK_SUBDOMAIN e.g. "mycompany" -> https://mycompany.zendesk.com ZENDESK_EMAIL agent email for API token auth ZENDESK_API_TOKEN Zendesk API token - Classification runs through the locally installed - Claude Code CLI. No Claude API key lives in this - deployment: the CLI authenticates with a subscription, - either the interactive login or CLAUDE_CODE_OAUTH_TOKEN. - CLAUDE_CODE_OAUTH_TOKEN - (optional) A `claude setup-token` token, passed through - to the CLI. Same subscription and billing as the - interactive login, with a year's life instead of weeks, - which is what an unattended host wants. + ANTHROPIC_API_KEY Claude API key (not needed with --findings or --dump-batch) ZENDESK_DISCORD_WEBHOOK_URL Discord incoming webhook for the triage channel, shared with resolve_reviews.py's tally and the failure alerts @@ -86,7 +78,7 @@ hydrate_requester_activity, is_store_review, review_platform, review_stars, ticket_url, zendesk_session) -from session_ops.zendesk.claude_cli import MODEL, claude_cli_json +from session_ops.zendesk.claude_api import claude_json, model as newest_opus # Never analyzed. A store review cannot be answered the way a ticket can: it takes # one developer response, replacing any previous one, with no way to ask a follow-up @@ -174,7 +166,7 @@ def window_label(hours): DEFAULT_BATCH_SIZE = 400 # A 400-ticket chunk at medium effort is minutes of work. Generous, because being -# killed mid-batch costs the whole chunk — and bounded, because a wedged CLI would +# killed mid-batch costs the whole chunk — and bounded, because a wedged request would # otherwise hold the digest until the unit's own TimeoutStartSec fires. CLAUDE_TIMEOUT_SECONDS = 1800 @@ -569,10 +561,9 @@ def tickets_from_payload(payload, source): return validate_findings(found, source) -def dump_batch(path, compact_tickets, model): +def dump_batch(path, compact_tickets): """Write the batch to disk so it can be classified by hand. Contains ticket text.""" payload = { - "model": model, "system_prompt": SYSTEM_PROMPT, "schema": SCHEMA, "tickets": compact_tickets, @@ -616,8 +607,8 @@ def analyze_in_chunks(analyzer, compact_tickets, batch_size): def analyze(model, effort, compact_tickets): - """Classify a batch through the Claude Code CLI. Returns findings.""" - payload = claude_cli_json( + """Classify a batch through the Claude API. Returns findings.""" + payload = claude_json( model, effort, SYSTEM_PROMPT, SCHEMA, build_analysis_prompt(compact_tickets), CLAUDE_TIMEOUT_SECONDS, f"a batch of {len(compact_tickets)} tickets") @@ -1114,13 +1105,13 @@ def main(argv=None): compact = [compact_ticket(t) for t in tickets] if args.dump_batch: - dump_batch(args.dump_batch, compact, MODEL) + dump_batch(args.dump_batch, compact) print(f"Wrote {len(compact)} tickets to {args.dump_batch} — this file contains " f"ticket content, so keep it out of the repo.") print("Classify it, then: --findings --dry-run") return - analyzer = partial(analyze, MODEL, args.effort) + analyzer = partial(analyze, newest_opus(), args.effort) findings = analyze_in_chunks(analyzer, compact, args.batch_size) # Keep only findings whose id maps to a fetched ticket, in case of drift. diff --git a/tests/goldens/units/dropins.txt b/tests/goldens/units/dropins.txt index 51ff8e7..e6d05ed 100644 --- a/tests/goldens/units/dropins.txt +++ b/tests/goldens/units/dropins.txt @@ -139,8 +139,4 @@ User=zendesk Group=zendesk EnvironmentFile=/etc/session-ops/zendesk.env TimeoutStartSec=90min -ProtectHome=tmpfs -BindPaths=/home/zendesk -ReadWritePaths=/home/zendesk -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX diff --git a/tests/monitor/test_alert.py b/tests/monitor/test_alert.py index b687bf1..4ea6fb4 100644 --- a/tests/monitor/test_alert.py +++ b/tests/monitor/test_alert.py @@ -8,24 +8,22 @@ from unittest import mock from session_ops.monitor import alert -from session_ops.zendesk import claude_cli +from session_ops.zendesk import claude_api -# What the digest itself left in the journal on 2026-09-23. JOURNAL = """\ Fetched 13 of 13 matching tickets (query: 'type:ticket status"))) + + def test_a_refused_key_gets_the_rotation_advice(self): + line = self.failure(raises=api_error(anthropic.AuthenticationError, 401, + "invalid x-api-key")) + self.assertTrue(claude_api.is_auth_failure(line)) + self.assertIn("ANTHROPIC_API_KEY", "".join(claude_api.advice(line))) + + def test_an_account_out_of_credit_is_not_told_to_rotate_the_key(self): + line = self.failure(raises=api_error( + anthropic.BadRequestError, 400, "Your credit balance is too low")) + self.assertFalse(claude_api.is_auth_failure(line)) + self.assertIn("out of credit", "".join(claude_api.advice(line))) + + def test_a_timeout_is_not_an_account_problem(self): + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") + line = self.failure(raises=anthropic.APITimeoutError(request=request)) + self.assertIn("in time", line) + self.assertEqual(claude_api.advice(line), []) - def test_a_missing_cli_says_what_to_install(self): - with self.assertRaises(SystemExit) as caught: - self.run_cli(raises=FileNotFoundError()) - self.assertIn("PATH", str(caught.exception)) + def test_the_failure_detail_is_clipped(self): + line = self.failure(raises=api_error(anthropic.InternalServerError, 500, "x" * 5000)) + self.assertLess(len(line), 500) - def test_a_wedged_cli_does_not_hang_the_run(self): - with self.assertRaises(SystemExit) as caught: - self.run_cli(raises=claude_cli.subprocess.TimeoutExpired("claude", 60)) - self.assertIn("60s", str(caught.exception)) + def test_the_model_is_the_newest_opus(self): + models = [SimpleNamespace(id="claude-opus-5", created_at=1), + SimpleNamespace(id="claude-opus-5-5", created_at=3), + SimpleNamespace(id="claude-fable-5-1", created_at=4)] + claude_api.model.cache_clear() + try: + with Patched(claude_api, client=lambda: FakeClient(models=models)): + self.assertEqual(claude_api.model(), "claude-opus-5-5") + finally: + claude_api.model.cache_clear() if __name__ == "__main__": From 74f798f81d8a137a1b6118012a10e4badfec485f Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 20 Oct 2026 10:38:13 +1100 Subject: [PATCH 2/4] refactor(token-expiry): drop the Claude Code token check The Zendesk jobs authenticate with an API key, which does not expire, so the first-seen fingerprinting, the [issued] table and the zendesk.env env file have nothing left to check. Claude-Session: https://claude.ai/code/session_01Rq44MGw9nHuwYAjFYr8tGL --- deploy/env/expiry.toml.example | 7 --- docs/jobs/token-expiry.md | 12 +--- src/session_ops/jobs.toml | 6 +- src/session_ops/monitor/token_expiry.py | 80 ++++--------------------- tests/goldens/units/dropins.txt | 1 - tests/monitor/test_token_expiry.py | 74 ++++------------------- 6 files changed, 29 insertions(+), 151 deletions(-) diff --git a/deploy/env/expiry.toml.example b/deploy/env/expiry.toml.example index 64d3ecc..556c2da 100644 --- a/deploy/env/expiry.toml.example +++ b/deploy/env/expiry.toml.example @@ -5,10 +5,3 @@ # Crowdin → Account Settings → API → Personal Access Tokens, the Expires column. # A date, or "never" when it does not expire or this host does not use it. #CROWDIN_API_TOKEN = "never" - -# Only for a Claude Code token installed before token-expiry first ran: it otherwise -# dates a token from the day it first saw it. Ignored once that token is replaced. -# The fingerprint is printed by a dry run of token-expiry: see docs/jobs/token-expiry.md. -#[issued.CLAUDE_CODE_OAUTH_TOKEN] -#fingerprint = "3f2a9c01b4de" -#date = 2025-11-20 diff --git a/docs/jobs/token-expiry.md b/docs/jobs/token-expiry.md index 0ae6a6d..06826c7 100644 --- a/docs/jobs/token-expiry.md +++ b/docs/jobs/token-expiry.md @@ -7,7 +7,7 @@ as Crowdin's, counts from 00:00 UTC that day. | | | | --- | --- | | Runs | `session-ops@token-expiry.timer`, daily at 09:00 Melbourne | -| Secrets | `/etc/session-ops/alerts.env`: `ALERT_DISCORD_WEBHOOK_URL`; `github-prs.env` and `zendesk.env` for the tokens it checks | +| Secrets | `/etc/session-ops/alerts.env`: `ALERT_DISCORD_WEBHOOK_URL`; `github-prs.env` for the GitHub token | | Dates | `/etc/session-ops/expiry.toml`, from [`expiry.toml.example`](../../deploy/env/expiry.toml.example) | | Dry run | `session-ops run token-expiry --dry-run` prints each token's expiry and the alert it would post. On the host, through `systemd-run` as in [deploy/README.md](../../deploy/README.md). | | Logs | `journalctl -u session-ops@token-expiry -n 50 --no-pager` | @@ -16,15 +16,9 @@ as Crowdin's, counts from 00:00 UTC that day. | --- | --- | | `GITHUB_PRS_TOKEN` | GitHub's `github-authentication-token-expiration` header; nothing to update on rotation | | `CROWDIN_API_TOKEN` | `expiry.toml`: the Expires column at https://crowdin.com/settings#api-key | -| `CLAUDE_CODE_OAUTH_TOKEN` | A year after the job first saw it, by fingerprint; nothing to update on rotation | `CROWDIN_API_TOKEN` is reported on every daily run until `expiry.toml` has a date or `"never"` for it. -The Claude Code token's first sighting lives in `/var/lib/session-ops/token-expiry/state.json`. -For a token installed before the job first ran, or if that file is lost, add its real -issue date under `[issued]` with the fingerprint the dry run prints; the entry is -ignored once the token changes. A date that is wrong is not caught: the Zendesk -digest's failure alert, which names a dead Claude login, is then the backstop. - -The Zendesk API token, the Discord webhooks and the GitHub App key do not expire. +The Zendesk API token, the Claude API key, the Discord webhooks and the GitHub App key +do not expire. diff --git a/src/session_ops/jobs.toml b/src/session_ops/jobs.toml index 931a7d4..cd8d59d 100644 --- a/src/session_ops/jobs.toml +++ b/src/session_ops/jobs.toml @@ -86,10 +86,8 @@ description = "Alert before a token the jobs use expires" entry = "session_ops.monitor.token_expiry:main" args = ["--state", "{state}/state.json"] user = "sessionops" -# For the GitHub token it probes and the Claude Code token it fingerprints, which brings -# the rest of zendesk.env's secrets into this unit. expiry.toml holds none. -env_files = ["/etc/session-ops/alerts.env", "/etc/session-ops/github-prs.env", - "/etc/session-ops/zendesk.env"] +# github-prs.env for the token it probes. expiry.toml holds no secrets. +env_files = ["/etc/session-ops/alerts.env", "/etc/session-ops/github-prs.env"] env = ["ALERT_DISCORD_WEBHOOK_URL"] schedule = "*-*-* 09:00 Australia/Melbourne" # A day, plus the hour a DST change adds and slack for the run. diff --git a/src/session_ops/monitor/token_expiry.py b/src/session_ops/monitor/token_expiry.py index 86fba44..6ce3f47 100644 --- a/src/session_ops/monitor/token_expiry.py +++ b/src/session_ops/monitor/token_expiry.py @@ -2,13 +2,9 @@ """ Alert before a token the jobs use expires. -Each token's expiry comes from one of three places: +Each token's expiry comes from one of two places: - GITHUB_PRS_TOKEN: GitHub's response header, so a rotation needs nothing updated. -- CLAUDE_CODE_OAUTH_TOKEN: a year after this job first saw it, by a fingerprint kept - in --state, so a rotation needs nothing updated either. A token installed before the - job existed gets its real issue date from [issued] in the expiry file, which applies - only while that same token is in place. - CROWDIN_API_TOKEN: exposes its expiry to no API, so it is recorded by hand, and reported on every run until it is. @@ -18,11 +14,6 @@ # A date, or "never" when it does not expire or this host does not use it. CROWDIN_API_TOKEN = "never" - [issued.CLAUDE_CODE_OAUTH_TOKEN] - # As printed by --dry-run. - fingerprint = "3f2a9c01b4de" - date = 2025-11-20 - Posts once as each token comes within 14 days, 7 days and 24 hours of expiring, and once more when it has expired; a new expiry date starts it over. Times are UTC, and a date without one counts from its start, so a run on any timezone's morning errs early. @@ -31,14 +22,12 @@ Config: ALERT_DISCORD_WEBHOOK_URL where the alert goes (not needed with --dry-run) GITHUB_PRS_TOKEN probed for its expiry; skipped when unset - CLAUDE_CODE_OAUTH_TOKEN fingerprinted; skipped when unset Usage: session-ops-token-expiry --state /var/lib/session-ops/token-expiry/state.json session-ops-token-expiry --dry-run # print every token's expiry and the alert """ import argparse -import hashlib import json import os import socket @@ -53,7 +42,7 @@ EXPIRY_FILE = "/etc/session-ops/expiry.toml" THRESHOLDS = (1, 7, 14) NEVER = "never" -GITHUB, RECORDED, FIRST_SEEN = "github", "recorded", "first seen" +GITHUB, RECORDED = "github", "recorded" GITHUB_RATE_LIMIT = "https://api.github.com/rate_limit" GITHUB_EXPIRY_HEADER = "github-authentication-token-expiration" @@ -63,7 +52,6 @@ class Token: env_file: str renew: str source: str - lifetime_days: int = None TOKENS = { @@ -72,17 +60,12 @@ class Token: GITHUB), "CROWDIN_API_TOKEN": Token("/etc/session-ops/crowdin.env", "https://crowdin.com/settings#api-key", RECORDED), - "CLAUDE_CODE_OAUTH_TOKEN": Token("/etc/session-ops/zendesk.env", - "`claude setup-token` on a machine with a browser, " - "then restart zendesk-relay", - FIRST_SEEN, lifetime_days=365), } @dataclass(frozen=True) class Config: expires: dict - issued: dict def start_of(day): @@ -94,12 +77,12 @@ def as_date(value): def load_config(path): - """The expiry file's [expires] and [issued]; empty when it does not exist yet.""" + """The expiry file's [expires]; empty when it does not exist yet.""" try: with open(path, "rb") as handle: data = tomllib.load(handle) except FileNotFoundError: - return Config({}, {}) + return Config({}) except tomllib.TOMLDecodeError as exc: sys.exit(f"{path}: {exc}") expires = {} @@ -108,34 +91,7 @@ def load_config(path): if not (isinstance(value, date) or value == NEVER): sys.exit(f"{path}: {name} must be a date (2027-03-01) or \"{NEVER}\", not {value!r}") expires[name] = value - issued = {} - for name, entry in data.get("issued", {}).items(): - when = as_date(entry.get("date")) if isinstance(entry, dict) else None - if not (isinstance(when, date) and isinstance(entry.get("fingerprint"), str)): - sys.exit(f"{path}: [issued.{name}] needs a fingerprint and a date") - issued[name] = (entry["fingerprint"], when) - return Config(expires, issued) - - -def fingerprint(value): - """Enough of a hash to tell tokens apart, and nothing that helps recover one.""" - return hashlib.sha256(value.encode()).hexdigest()[:12] - - -def issued_on(name, value, issued, seen, today): - """When the token in `value` was issued: its [issued] date while that names this - token, else the day it was first seen. Records it in `seen`.""" - current = fingerprint(value) - recorded = issued.get(name) - entry = seen.get(name, {}) - if recorded and recorded[0] == current: - since = recorded[1] - elif entry.get("fingerprint") == current: - since = date.fromisoformat(entry["since"]) - else: - since = today - seen[name] = {"fingerprint": current, "since": since.isoformat()} - return since + return Config(expires) def parse_github_expiry(value): @@ -224,8 +180,7 @@ def utc_now(): def load_state(path): - """{"alerts": ..., "seen": ...}. Losing it re-dates a fingerprinted token from the - next run, which alerts late: the reason it is saved even when a post fails.""" + """{"alerts": ...}: what has been alerted, so each window posts once.""" data = {} if path and os.path.exists(path): try: @@ -234,7 +189,7 @@ def load_state(path): except (OSError, ValueError): data = {} data = data if isinstance(data, dict) else {} - return {"alerts": data.get("alerts", {}), "seen": data.get("seen", {})} + return {"alerts": data.get("alerts", {})} def save_state(path, state): @@ -244,22 +199,16 @@ def save_state(path, state): os.replace(temporary, path) -def collect(config, environ, seen, session, now): - """{name: expiry} for every token in use. A token set nowhere is left out, and so - is its fingerprint.""" +def collect(config, environ, session): + """{name: expiry} for every token in use. A token set nowhere is left out.""" expiries = {} for name, token in TOKENS.items(): value = environ.get(name) if token.source == RECORDED: expiry = config.expires.get(name, "missing") expiries[name] = start_of(expiry) if isinstance(expiry, date) else expiry - elif not value: - seen.pop(name, None) - elif token.source == GITHUB: + elif value: expiries[name] = probe_github(session, value) - else: - since = issued_on(name, value, config.issued, seen, now.date()) - expiries[name] = start_of(since + timedelta(days=token.lifetime_days)) return expiries @@ -276,15 +225,10 @@ def main(argv=None): webhook = get_env("ALERT_DISCORD_WEBHOOK_URL", args.webhook, required=not args.dry_run) now = utc_now() state = load_state(args.state) - expiries = collect(load_config(args.expiry_file), os.environ, state["seen"], - http.Session(), now) + expiries = collect(load_config(args.expiry_file), os.environ, http.Session()) for name in TOKENS: expiry = expiries.get(name, "not set") - line = f"{name}: {shown(expiry) if isinstance(expiry, datetime) else expiry}" - seen = state["seen"].get(name) - if seen: - line += f" (fingerprint {seen['fingerprint']}, issued {seen['since']})" - print(line) + print(f"{name}: {shown(expiry) if isinstance(expiry, datetime) else expiry}") due = evaluate(expiries, state["alerts"], now) persist = bool(args.state) and not args.dry_run diff --git a/tests/goldens/units/dropins.txt b/tests/goldens/units/dropins.txt index e6d05ed..f6a4fee 100644 --- a/tests/goldens/units/dropins.txt +++ b/tests/goldens/units/dropins.txt @@ -118,7 +118,6 @@ User=sessionops Group=sessionops EnvironmentFile=/etc/session-ops/alerts.env EnvironmentFile=/etc/session-ops/github-prs.env -EnvironmentFile=/etc/session-ops/zendesk.env TimeoutStartSec=5min ==> session-ops@token-expiry.timer.d/schedule.conf <== diff --git a/tests/monitor/test_token_expiry.py b/tests/monitor/test_token_expiry.py index 5147e2e..fe40a97 100644 --- a/tests/monitor/test_token_expiry.py +++ b/tests/monitor/test_token_expiry.py @@ -17,7 +17,6 @@ def utc(*args): # 09:00 Melbourne on 6 October, the job's schedule: still the 5th in UTC. NOW = utc(2026, 10, 5, 22) -TODAY = NOW.date() def expiry_file(text): @@ -27,76 +26,30 @@ def expiry_file(text): return handle.name -CLAUDE = "CLAUDE_CODE_OAUTH_TOKEN" -YEAR = timedelta(days=365) - - class TestConfig(unittest.TestCase): - def test_reads_expires_and_issued(self): - config = token_expiry.load_config(expiry_file( - '[expires]\nCROWDIN_API_TOKEN = 2027-03-01\n' - '[issued.CLAUDE_CODE_OAUTH_TOKEN]\nfingerprint = "abc"\ndate = 2025-11-20\n')) + def test_reads_expires(self): + config = token_expiry.load_config(expiry_file('[expires]\nCROWDIN_API_TOKEN = 2027-03-01\n')) self.assertEqual(config.expires, {"CROWDIN_API_TOKEN": date(2027, 3, 1)}) - self.assertEqual(config.issued, {CLAUDE: ("abc", date(2025, 11, 20))}) def test_a_missing_file_records_nothing(self): self.assertEqual(token_expiry.load_config("/nonexistent/expiry.toml"), - token_expiry.Config({}, {})) + token_expiry.Config({})) def test_refuses_an_expiry_that_is_neither_a_date_nor_never(self): with self.assertRaises(SystemExit): token_expiry.load_config(expiry_file('[expires]\nCROWDIN_API_TOKEN = "March"\n')) - def test_refuses_an_issued_entry_without_a_fingerprint(self): - with self.assertRaises(SystemExit): - token_expiry.load_config(expiry_file('[issued.CLAUDE_CODE_OAUTH_TOKEN]\n' - 'date = 2025-11-20\n')) - class TestCollect(unittest.TestCase): - def collect(self, environ, seen=None, config=None, now=NOW): - seen = {} if seen is None else seen - expiries = token_expiry.collect(config or token_expiry.Config({}, {}), environ, - seen, None, now) - return expiries, seen + def collect(self, environ, config=None): + return token_expiry.collect(config or token_expiry.Config({}), environ, None) def test_crowdin_without_a_date_is_missing_and_unset_tokens_are_left_out(self): - expiries, _ = self.collect({}) - self.assertEqual(expiries, {"CROWDIN_API_TOKEN": "missing"}) + self.assertEqual(self.collect({}), {"CROWDIN_API_TOKEN": "missing"}) def test_a_recorded_date_counts_from_its_start_in_utc(self): - config = token_expiry.Config({"CROWDIN_API_TOKEN": date(2027, 3, 1)}, {}) - self.assertEqual(self.collect({}, config=config)[0]["CROWDIN_API_TOKEN"], - utc(2027, 3, 1)) - - def test_a_new_claude_token_expires_a_year_after_it_was_first_seen(self): - expiries, seen = self.collect({CLAUDE: "tok-a"}) - self.assertEqual(expiries[CLAUDE], utc(2027, 10, 5)) - later, _ = self.collect({CLAUDE: "tok-a"}, seen, now=NOW + timedelta(days=30)) - self.assertEqual(later[CLAUDE], utc(2027, 10, 5)) - - def test_a_replaced_token_starts_a_new_year(self): - _, seen = self.collect({CLAUDE: "tok-a"}) - expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, now=utc(2027, 8, 1, 22)) - self.assertEqual(expiries[CLAUDE], utc(2028, 7, 31)) - - def test_the_issued_date_applies_only_to_the_token_it_names(self): - config = token_expiry.Config({}, {CLAUDE: (token_expiry.fingerprint("tok-a"), - date(2025, 11, 20))}) - expiries, seen = self.collect({CLAUDE: "tok-a"}, config=config) - self.assertEqual(expiries[CLAUDE], utc(2026, 11, 20)) - expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, config) - self.assertEqual(expiries[CLAUDE], utc(2027, 10, 5)) - - def test_the_state_holds_a_fingerprint_not_the_token(self): - _, seen = self.collect({CLAUDE: "tok-a"}) - self.assertNotIn("tok-a", repr(seen)) - self.assertEqual(seen[CLAUDE]["fingerprint"], token_expiry.fingerprint("tok-a")) - - def test_an_unset_token_forgets_its_fingerprint(self): - _, seen = self.collect({CLAUDE: "tok-a"}) - self.collect({}, seen) - self.assertEqual(seen, {}) + config = token_expiry.Config({"CROWDIN_API_TOKEN": date(2027, 3, 1)}) + self.assertEqual(self.collect({}, config)["CROWDIN_API_TOKEN"], utc(2027, 3, 1)) class FakeResponse: @@ -202,7 +155,7 @@ def test_an_expired_token_says_when(self): self.assertIn("**expired** at 2026-10-05 03:00 UTC", message) def test_a_missing_date_names_the_file(self): - message = token_expiry.build_message([("CLAUDE_CODE_OAUTH_TOKEN", "missing", "missing")], + message = token_expiry.build_message([("CROWDIN_API_TOKEN", "missing", "missing")], "box", "/x/expiry.toml") self.assertIn("no expiry date in `/x/expiry.toml`", message) self.assertNotIn("Renew", message) @@ -221,19 +174,16 @@ def test_a_delivered_alert_is_recorded_and_not_repeated(self): token_expiry.main(argv) self.assertEqual(post.call_count, 1) - def test_a_failed_post_keeps_the_fingerprint_but_records_no_alert(self): + def test_a_failed_post_records_no_alert(self): state = os.path.join(tempfile.mkdtemp(), "state.json") path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') - with mock.patch.dict(os.environ, {CLAUDE: "tok-a"}, clear=True), \ + with mock.patch.dict(os.environ, {}, clear=True), \ mock.patch.object(token_expiry, "utc_now", return_value=NOW), \ mock.patch.object(token_expiry.discord, "post_to_discord", return_value=0), \ self.assertRaises(SystemExit): token_expiry.main(["--expiry-file", path, "--state", state, "--webhook", "https://example.invalid"]) - saved = token_expiry.load_state(state) - self.assertEqual(saved["alerts"], {}) - self.assertEqual(saved["seen"][CLAUDE]["since"], TODAY.isoformat()) - + self.assertEqual(token_expiry.load_state(state)["alerts"], {}) if __name__ == "__main__": unittest.main() From 077423dc60014b29f687f5efd6e81acccaa1e683 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 20 Oct 2026 11:00:51 +1100 Subject: [PATCH 3/4] build: lock anthropic Claude-Session: https://claude.ai/code/session_01EZqfmPqVe37M8zBKY4udPb --- uv.lock | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/uv.lock b/uv.lock index 56748af..d72142c 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anthropic" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/37/5a2a4316df8babd28f096477a707cd75f240b4264c97fb128c32375105d6/anthropic-1.15.0.tar.gz", hash = "sha256:90caa9df83e2fc8bddb4ddeb83b7191bce255f635c03eae055deec521741df08", size = 1726101, upload-time = "2026-10-15T19:45:34.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/65/54437e7edea49a3be9ef56f6b5965999bfdc52b9dadc9b448124ca51a594/anthropic-1.15.0-py3-none-any.whl", hash = "sha256:10189a7993c5c71a0a2d4bb80020ca6a2fd27679f51e627405da37251d15ff49", size = 2047755, upload-time = "2026-10-15T19:45:32.951Z" }, +] + [[package]] name = "anyio" version = "4.15.1" @@ -399,6 +418,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -472,6 +500,131 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -692,6 +845,7 @@ name = "session-ops" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "babel" }, { name = "colorama" }, { name = "crowdin-api-client" }, @@ -710,6 +864,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=1.15" }, { name = "babel", specifier = "==2.17.0" }, { name = "colorama", specifier = ">=0.4.6" }, { name = "crowdin-api-client", specifier = ">=1.29.0" }, @@ -726,6 +881,15 @@ dev = [ { name = "ruff", specifier = "==0.16.9" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "starlette" version = "1.7.0" From 268f5b1a75700e1e23d778934aba091d914360d8 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 20 Oct 2026 11:00:52 +1100 Subject: [PATCH 4/4] fix(zendesk): resolve the model at the first Claude call A failed model lookup in note_reply's main() exited before any note was written, and stopped solve and reply, which never call Claude. claude_json now resolves the newest Opus itself, inside the callers' existing failure handling. Claude-Session: https://claude.ai/code/session_01EZqfmPqVe37M8zBKY4udPb --- src/session_ops/zendesk/claude_api.py | 8 +-- src/session_ops/zendesk/note_reply.py | 36 ++++++------- src/session_ops/zendesk/triage.py | 4 +- tests/zendesk/test_note_reply.py | 75 ++++++++++++++++----------- tests/zendesk/test_triage.py | 13 +++-- 5 files changed, 77 insertions(+), 59 deletions(-) diff --git a/src/session_ops/zendesk/claude_api.py b/src/session_ops/zendesk/claude_api.py index 5edd2d8..945a0fb 100644 --- a/src/session_ops/zendesk/claude_api.py +++ b/src/session_ops/zendesk/claude_api.py @@ -54,16 +54,18 @@ def failure(exc, label): return f"{API_PREFIX} was unreachable on {label}: {message}" -def claude_json(model, effort, system_prompt, schema, prompt, timeout, label): - """Run one schema-enforced Claude request. Returns the parsed payload. +def claude_json(effort, system_prompt, schema, prompt, timeout, label, model_id=None): + """Run one schema-enforced Claude request on model_id, by default the newest Opus. + Returns the parsed payload. Shared by the digest's classification and note_reply.py's composing: same request shape, same error semantics, one place to keep them right. Every failure exits with a line starting API_PREFIX, which is what advice() reads. """ + model_id = model_id or model() try: with client().with_options(timeout=timeout).messages.stream( - model=model, + model=model_id, max_tokens=MAX_OUTPUT_TOKENS, system=system_prompt, messages=[{"role": "user", "content": prompt}], diff --git a/src/session_ops/zendesk/note_reply.py b/src/session_ops/zendesk/note_reply.py index 8d7b042..dd7bf8b 100644 --- a/src/session_ops/zendesk/note_reply.py +++ b/src/session_ops/zendesk/note_reply.py @@ -437,13 +437,13 @@ def tagged_placement(ticket): ).strip() -def place_ticket(model, book, ticket, sample): +def place_ticket(book, ticket, sample): """Which group and platform this ticket belongs to. (None, None) if unplaceable.""" catalogue = "\n".join(f"- {g['key']}: {g['title']}" for g in book["groups"]) body = clip(sample, CUSTOMER_SAMPLE_CHARS) try: found = claude_api.claude_json( - model, "medium", PLACEMENT_SYSTEM, PLACEMENT_SCHEMA, + "medium", PLACEMENT_SYSTEM, PLACEMENT_SCHEMA, f"GROUP CATALOGUE:\n{catalogue}\n\nTHE TICKET:\n" f"{(ticket.get('subject') or '')[:200]}\n\n{body}", PLACEMENT_TIMEOUT_SECONDS, f"the placement of #{ticket['id']}") @@ -704,9 +704,9 @@ def validate_composition(result): return result -def compose(model, sample, brief, previous=None, precedent=None, thread=None): +def compose(sample, brief, previous=None, precedent=None, thread=None): return validate_composition(claude_api.claude_json( - model, "medium", COMPOSE_SYSTEM, COMPOSE_SCHEMA, + "medium", COMPOSE_SYSTEM, COMPOSE_SCHEMA, build_compose_prompt(sample, brief, previous, precedent, thread), COMPOSE_TIMEOUT_SECONDS, "the reply draft")) @@ -861,7 +861,7 @@ def build_sent_note(user, comment_id, sent): # ---- Actions ---------------------------------------------------------------- -def run_draft(session, subdomain, model, ticket, comments, command, api_user, dry_run): +def run_draft(session, subdomain, ticket, comments, command, api_user, dry_run): """Compose the reply, or revise the one already on the ticket. A second `draft` on a ticket that already has one is an amendment, not a fresh @@ -892,7 +892,7 @@ def run_draft(session, subdomain, model, ticket, comments, command, api_user, dr group, platform = tagged_placement(ticket) new_tags = [] if book and not group: - group, platform = place_ticket(model, book, ticket, sample) + group, platform = place_ticket(book, ticket, sample) # Cached on the ticket so a revision does not pay for the same call again, # and so the placement is visible to a human who disagrees with it. new_tags = ([f"{TAG_GROUP_PREFIX}{group}"] if group else []) + \ @@ -906,7 +906,7 @@ def run_draft(session, subdomain, model, ticket, comments, command, api_user, dr f"({cell['n']} solved, {cell['consistency']} consistency).") try: - result = compose(model, clip(sample, CUSTOMER_SAMPLE_CHARS), brief, + result = compose(clip(sample, CUSTOMER_SAMPLE_CHARS), brief, previous, precedent, thread) except SystemExit as exc: say(session, subdomain, ticket_id, comment_id, @@ -962,7 +962,7 @@ def run_solve(session, subdomain, ticket, command, dry_run): print(f"#{ticket_id}: solved, no reply sent.") -def run_explain(session, subdomain, model, ticket, comments, command, dry_run): +def run_explain(session, subdomain, ticket, comments, command, dry_run): """Say what we already know about this kind of ticket, without writing a reply. The house answer, the steps, and — the reason this verb exists — what was @@ -982,8 +982,8 @@ def run_explain(session, subdomain, model, ticket, comments, command, dry_run): new_tags = [] if not group: group, platform = place_ticket( - model, book, ticket, api.customer_text(session, subdomain, ticket, comments, - CUSTOMER_SAMPLE_CHARS)) + book, ticket, api.customer_text(session, subdomain, ticket, comments, + CUSTOMER_SAMPLE_CHARS)) new_tags = ([f"{TAG_GROUP_PREFIX}{group}"] if group else []) + \ ([f"{TAG_PLATFORM_PREFIX}{platform}"] if platform else []) cell, covering = house_cell(book, group, platform) @@ -1135,7 +1135,7 @@ def fit_turns(turns, limit): room -= excess -def translate_conversation(session, subdomain, model, ticket, comments): +def translate_conversation(session, subdomain, ticket, comments): """The conversation on a ticket in English, or why there is none. Returns (outcome, detail): ("current", None) when a transcript already covers the @@ -1161,7 +1161,7 @@ def translate_conversation(session, subdomain, model, ticket, comments): turns, omitted = fit_turns(turns, transcript.TRANSCRIPT_INPUT_CHARS) try: rendered = claude_api.claude_json( - model, "medium", transcript.TRANSCRIPT_SYSTEM_PROMPT, transcript.TRANSCRIPT_SCHEMA, + "medium", transcript.TRANSCRIPT_SYSTEM_PROMPT, transcript.TRANSCRIPT_SCHEMA, transcript_payload(turns), transcript.ENGLISH_TIMEOUT_SECONDS, f"the English transcript of #{ticket_id}") except SystemExit as exc: @@ -1191,10 +1191,10 @@ def transcript_landed(session, subdomain, ticket_id, latest): return bool(comments) and api.has_marker(comments, english_marker(latest)) -def run_english(session, subdomain, model, ticket, comments, command, dry_run): +def run_english(session, subdomain, ticket, comments, command, dry_run): """Put the conversation on the ticket in English, as a private note.""" ticket_id = ticket["id"] - outcome, detail = translate_conversation(session, subdomain, model, ticket, comments) + outcome, detail = translate_conversation(session, subdomain, ticket, comments) answers = { "current": "The English transcript on this ticket is already up to date — nothing " "has been said since it was written.", @@ -1287,8 +1287,6 @@ def main(argv=None): parser.add_argument("--dry-run", action="store_true", help="do everything except write to Zendesk") args = parser.parse_args(argv) - model = claude_api.model() - subdomain = get_env("ZENDESK_SUBDOMAIN") session = api.zendesk_session(get_env("ZENDESK_EMAIL"), get_env("ZENDESK_API_TOKEN")) @@ -1313,15 +1311,15 @@ def main(argv=None): return if command["action"] == "draft": - run_draft(session, subdomain, model, ticket, comments, command, + run_draft(session, subdomain, ticket, comments, command, api_user, args.dry_run) elif command["action"] == "solve": run_solve(session, subdomain, ticket, command, args.dry_run) elif command["action"] == "explain": - run_explain(session, subdomain, model, ticket, comments, command, + run_explain(session, subdomain, ticket, comments, command, args.dry_run) elif command["action"] == "english": - run_english(session, subdomain, model, ticket, comments, command, args.dry_run) + run_english(session, subdomain, ticket, comments, command, args.dry_run) else: run_reply(session, subdomain, ticket, comments, command, api_user, args.dry_run) diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index d623fe6..9346897 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -609,9 +609,9 @@ def analyze_in_chunks(analyzer, compact_tickets, batch_size): def analyze(model, effort, compact_tickets): """Classify a batch through the Claude API. Returns findings.""" payload = claude_json( - model, effort, SYSTEM_PROMPT, SCHEMA, + effort, SYSTEM_PROMPT, SCHEMA, build_analysis_prompt(compact_tickets), CLAUDE_TIMEOUT_SECONDS, - f"a batch of {len(compact_tickets)} tickets") + f"a batch of {len(compact_tickets)} tickets", model_id=model) return tickets_from_payload(payload, "Claude") diff --git a/tests/zendesk/test_note_reply.py b/tests/zendesk/test_note_reply.py index 29893ad..94fc94c 100644 --- a/tests/zendesk/test_note_reply.py +++ b/tests/zendesk/test_note_reply.py @@ -20,6 +20,7 @@ import json import io import os +import sys import re import unittest from unittest import mock @@ -148,7 +149,7 @@ def test_an_up_to_date_transcript_is_not_re_rendered(self): dict(comment("hallo", author=42, cid=7), public=True), prior] with Patched(api, conversation_turns=lambda *a: called.append(a)): session = fake_session(*[FakeResponse({"ticket": {}})]) - note_reply.run_english(session, "sub", "model", {"id": 7}, comments, + note_reply.run_english(session, "sub", {"id": 7}, comments, {"id": 9, "author": AGENT, "action": "english", "brief": ""}, dry_run=False) self.assertEqual(called, []) @@ -162,7 +163,7 @@ def test_nothing_new_is_not_an_error(self): dict(comment("hallo", author=42, cid=7), public=True), prior] session = fake_session(*[FakeResponse({"ticket": {}})]) with Patched(api, conversation_turns=lambda *a: None): - note_reply.run_english(session, "sub", "model", {"id": 7}, comments, + note_reply.run_english(session, "sub", {"id": 7}, comments, {"id": 9, "author": AGENT, "action": "english", "brief": ""}, dry_run=False) # the note itself carries no tag fields; the tag work is separate calls @@ -181,7 +182,7 @@ def test_an_english_ticket_gets_no_transcript(self): with Patched(api, conversation_turns=lambda *a: turns), \ Patched(claude_api, claude_json=lambda *a, **k: { "turns": [{"index": 0, "english": "My app crashes"}]}): - note_reply.run_english(session, "sub", "model", {"id": 7}, + note_reply.run_english(session, "sub", {"id": 7}, [comment("claude: english", cid=9)], {"id": 9, "author": AGENT, "action": "english", "brief": ""}, dry_run=False) @@ -197,7 +198,7 @@ def fail(*a, **k): FakeResponse({})]) with Patched(api, conversation_turns=lambda *a: turns), \ Patched(claude_api, claude_json=fail): - note_reply.run_english(session, "sub", "model", {"id": 7}, + note_reply.run_english(session, "sub", {"id": 7}, [comment("claude: english", cid=9)], {"id": 9, "author": AGENT, "action": "english", "brief": ""}, dry_run=False) @@ -217,7 +218,7 @@ def test_a_transcript_posted_meanwhile_is_not_posted_again(self): Patched(claude_api, claude_json=lambda *a, **k: { "turns": [{"index": 0, "english": "Hello"}]}), \ contextlib.redirect_stdout(io.StringIO()): - note_reply.run_english(session, "sub", "model", {"id": 7}, + note_reply.run_english(session, "sub", {"id": 7}, [comment("claude: english", cid=9), comment("Hallo", author=42, public=True, cid=3)], {"id": 9, "author": AGENT, "action": "english", @@ -300,7 +301,7 @@ def test_the_transcript_note_is_never_public(self): with Patched(api, conversation_turns=lambda *a: turns), \ Patched(claude_api, claude_json=lambda *a, **k: { "turns": [{"index": 0, "english": "Hello"}]}): - note_reply.run_english(session, "sub", "model", {"id": 7}, + note_reply.run_english(session, "sub", {"id": 7}, [dict(comment("Hallo", author=42, cid=3), public=True)], {"id": 9, "author": AGENT, "action": "english", "brief": ""}, dry_run=False) @@ -339,14 +340,14 @@ def test_only_the_turns_that_fit_are_sent_and_judged(self): turns = self.turns("a" * 7990, "Es geht nicht.") sent = [] - def translate(model, effort, system, schema, payload, *a): + def translate(effort, system, schema, payload, *a): sent.append(json.loads(payload)) return {"turns": [{"index": 1, "english": "It does not work."}]} with Patched(api, conversation_turns=lambda *a: turns), \ Patched(claude_api, claude_json=translate): outcome, detail = note_reply.translate_conversation( - None, "sub", "model", {"id": 7}, [comment("x", public=True, cid=3)]) + None, "sub", {"id": 7}, [comment("x", public=True, cid=3)]) self.assertEqual(outcome, "ready") self.assertEqual([t["index"] for t in sent[0]], [1]) self.assertEqual(detail[3], 1) @@ -672,7 +673,7 @@ def test_it_shows_what_was_actually_done(self): session = fake_session(*[FakeResponse({"ticket": {}})]) with Patched(note_reply, load_house=lambda *a: book): note_reply.run_explain( - session, "sub", "model", + session, "sub", {"id": 7, "tags": ["grp-attachments", "plat-android"]}, [], {"id": 9, "author": AGENT, "action": "explain", "brief": ""}, dry_run=False) @@ -685,7 +686,7 @@ def test_it_writes_nothing_when_the_ticket_matches_nothing(self): with Patched(note_reply, load_house=lambda *a: BOOK, place_ticket=lambda *a: (None, "android")): session = fake_session(*[FakeResponse({"ticket": {}})]) - note_reply.run_explain(session, "sub", "model", {"id": 7, "tags": []}, [], + note_reply.run_explain(session, "sub", {"id": 7, "tags": []}, [], {"id": 9, "author": AGENT, "action": "explain", "brief": ""}, dry_run=False) self.assertIn("no precedent", @@ -697,7 +698,7 @@ def test_it_writes_nothing_when_the_ticket_matches_nothing(self): def test_no_house_answers_configured_is_said_plainly(self): with Patched(note_reply, load_house=lambda *a: None): session = fake_session(*[FakeResponse({"ticket": {}})]) - note_reply.run_explain(session, "sub", "model", {"id": 7, "tags": []}, [], + note_reply.run_explain(session, "sub", {"id": 7, "tags": []}, [], {"id": 9, "author": AGENT, "action": "explain", "brief": ""}, dry_run=False) self.assertIn("No house answers", @@ -824,7 +825,7 @@ def test_an_empty_brief_is_refused_without_calling_claude(self): called = [] with Patched(note_reply, compose=lambda *a: called.append(a)): session = fake_session(*[FakeResponse({"ticket": {}})]) - note_reply.run_draft(session, "sub", "model", {"id": 7}, [], + note_reply.run_draft(session, "sub", {"id": 7}, [], {"id": 3, "author": AGENT, "action": "draft", "brief": ""}, API_USER, dry_run=False) self.assertEqual(called, []) @@ -876,12 +877,12 @@ class FollowUps(unittest.TestCase): def draft(self, comments, brief="still broken, ask for logs", lookups=()): seen = {} - def fake(model, sample, brief, previous=None, precedent=None, thread=None): + def fake(sample, brief, previous=None, precedent=None, thread=None): seen.update(sample=sample, previous=previous, thread=thread) return GERMAN with Patched(note_reply, compose=fake): session = fake_session(*lookups, FakeResponse({"ticket": {}})) - note_reply.run_draft(session, "sub", "model", TICKET, + note_reply.run_draft(session, "sub", TICKET, [comment(f"claude: draft - {brief}", cid=20)] + comments, {"id": 20, "author": AGENT, "action": "draft", "brief": brief}, API_USER, dry_run=False) @@ -1096,13 +1097,13 @@ def test_a_second_draft_amends_the_first(self): """An agent writing "also mention X" wants the draft they just read plus X. Regenerating from the new brief alone throws away wording they kept.""" seen = {} - def fake(model, sample, brief, previous=None, precedent=None, thread=None): + def fake(sample, brief, previous=None, precedent=None, thread=None): seen.update(brief=brief, previous=previous, precedent=precedent) return GERMAN with Patched(note_reply, compose=fake): session = fake_session(*[FakeResponse({"ticket": {}})]) note_reply.run_draft( - session, "sub", "model", {"id": 7, "requester_id": 42}, + session, "sub", {"id": 7, "requester_id": 42}, [comment("claude: draft - also mention X", cid=11), draft_note("Erster Entwurf", cid=10)], {"id": 11, "author": AGENT, "action": "draft", @@ -1120,7 +1121,7 @@ def fail(*a, **k): session = fake_session(*[FakeResponse({"ticket": {}}), FakeResponse({}), FakeResponse({})]) note_reply.run_draft( - session, "sub", "model", {"id": 7, "requester_id": 42}, + session, "sub", {"id": 7, "requester_id": 42}, [comment("claude: draft - x", cid=11)], {"id": 11, "author": AGENT, "action": "draft", "brief": "x"}, API_USER, dry_run=False) @@ -1135,13 +1136,13 @@ def fail(*a, **k): def test_a_first_draft_has_nothing_to_amend(self): seen = {} - def fake(model, sample, brief, previous=None, precedent=None, thread=None): + def fake(sample, brief, previous=None, precedent=None, thread=None): seen.update(previous=previous, precedent=precedent) return GERMAN with Patched(note_reply, compose=fake): session = fake_session(*[FakeResponse({"ticket": {}})]) note_reply.run_draft( - session, "sub", "model", {"id": 7, "requester_id": 42}, + session, "sub", {"id": 7, "requester_id": 42}, [comment("claude: draft - x", cid=11)], {"id": 11, "author": AGENT, "action": "draft", "brief": "x"}, API_USER, dry_run=False) @@ -1286,29 +1287,41 @@ def test_the_brief_is_bounded(self): class Model(unittest.TestCase): - def model_for(self, flag): - seen = [] + def run_main(self, action, flag=()): env = {"ZENDESK_SUBDOMAIN": "sub", "ZENDESK_EMAIL": "e", "ZENDESK_API_TOKEN": "t"} - command = {"id": 9, "author": AGENT, "action": "draft", "brief": "x"} + command = {"id": 9, "author": AGENT, "action": action, "brief": "x"} + ran = [] with mock.patch.dict(os.environ, env), \ Patched(api, zendesk_session=lambda *a: object(), fetch_ticket=lambda *a: {"id": 7, "status": "open"}, fetch_comments=lambda *a, **k: []), \ Patched(note_reply, api_user_id=lambda *a: API_USER, latest_command=lambda *a: command, - run_draft=lambda session, subdomain, model, *a: seen.append(model)): + run_draft=lambda *a: ran.append("draft"), + run_solve=lambda *a: ran.append("solve")), \ + Patched(claude_api, model=lambda: sys.exit("Claude API is down")): note_reply.main(["--ticket", "7", *flag]) - return seen[0] + return ran - def test_every_call_uses_the_newest_opus(self): - with Patched(claude_api, model=lambda: "claude-opus-9"): - self.assertEqual(self.model_for([]), "claude-opus-9") + def test_a_command_that_needs_no_claude_runs_without_the_model_lookup(self): + self.assertEqual(self.run_main("solve"), ["solve"]) - def test_there_is_no_model_flag(self): - with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()), \ - Patched(claude_api, model=lambda: "claude-opus-9"): - self.model_for(["--model", "sonnet"]) + def test_the_model_lookup_waits_for_the_claude_call(self): + self.assertEqual(self.run_main("draft"), ["draft"]) + def test_there_is_no_model_flag(self): + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + self.run_main("draft", ["--model", "sonnet"]) + + def test_a_failed_model_lookup_is_reported_on_the_ticket(self): + session = fake_session(*[FakeResponse({"ticket": {}})] * 3) + with Patched(note_reply, load_house=lambda *a: None, + place_ticket=lambda *a: (None, None)), \ + Patched(claude_api, model=lambda: sys.exit("Claude API is down")): + note_reply.run_draft(session, "sub", {"id": 7}, [], + {"id": 3, "author": AGENT, "action": "draft", + "brief": "say hi"}, API_USER, dry_run=False) + self.assertIn("Claude API is down", str(session.calls)) if __name__ == "__main__": unittest.main() diff --git a/tests/zendesk/test_triage.py b/tests/zendesk/test_triage.py index ee888c9..fd112c5 100644 --- a/tests/zendesk/test_triage.py +++ b/tests/zendesk/test_triage.py @@ -1701,11 +1701,12 @@ def reply(text='{"a": "b"}', stop_reason="end_turn"): class TestClaudeApi(unittest.TestCase): SCHEMA = {"type": "object", "properties": {"a": {"type": "string"}}} - def call(self, message=None, raises=None): + def call(self, message=None, raises=None, model_id="claude-opus-5-5"): self.client = FakeClient(reply() if message is None else message, raises) - with Patched(claude_api, client=lambda: self.client): - return claude_api.claude_json("claude-opus-5-5", "medium", "be terse", - self.SCHEMA, "ticket text", 60, "a batch of 3") + with Patched(claude_api, client=lambda: self.client, + model=lambda: "claude-opus-9"): + return claude_api.claude_json("medium", "be terse", self.SCHEMA, "ticket text", + 60, "a batch of 3", model_id=model_id) def failure(self, **kwargs): with self.assertRaises(SystemExit) as caught: @@ -1722,6 +1723,10 @@ def test_the_request_carries_the_schema_effort_and_timeout(self): "effort": "medium", "format": {"type": "json_schema", "schema": self.SCHEMA}}) self.assertEqual(self.client.options, [{"timeout": 60}]) + def test_without_a_model_id_the_request_uses_the_newest_opus(self): + self.call(model_id=None) + self.assertEqual(self.client.calls[0]["model"], "claude-opus-9") + def test_running_out_of_output_tokens_names_the_fix(self): self.assertIn("--batch-size", self.failure(message=reply('{"a": "b', stop_reason="max_tokens")))