Skip to content

skills: land litellm-gateway runbook and prod-deploy-via-release-only - #61

Merged
omp-harness[bot] merged 31 commits into
mainfrom
skills/litellm-gateway-and-prod-deploy
Oct 31, 2026
Merged

omp-harness[bot] merged 31 commits into
mainfrom
skills/litellm-gateway-and-prod-deploy

Conversation

@omp-harness

@omp-harness omp-harness Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

User description

Theme

Land two skills that lived outside the repo's skills/ tree: litellm-gateway
(previously installed directly under ~/.omp/agent/skills/, so never versioned,
reviewed, or refreshed by make install) and prod-deploy-via-release-only
(authored on an unmerged branch, which left make install with a dangling
symlink).

Key changes

skills/litellm-gateway/SKILL.md (canonical source)

The runbook for the local LiteLLM proxy at http://localhost:4000/v1, rewritten
so an agent does not have to reason about blue-green state:

  • Sides are defined — BLUE and GREEN are the two config files,
    config.current.yaml symlinks the live one, and "sides identical" means
    nothing is staged.
  • The tool decides, the skill points — run swap.sh status and execute the
    next: line it prints; no decision table to keep in sync by hand.
  • Member failures are reported, not judged — test.sh prints each failing
    member's HTTP status, error type and message plus which members ride instead;
    whether an exhausted-credit member is acceptable is the operator's call.
  • Rollback semantics stated once — rollback reverts a soak; the undo for a
    promotion comes from swap.sh promote's own dry-run output.
  • Model override scoped — change a model in the LiteLLM chain; override
    ~/.paseo/config.json only when the user explicitly asks for that session.

The gateway tooling lives in ashbywinch/bluegreen-config (swap.sh,
test.sh, snapshot-live.sh) and is deployed into ~/.paseo/litellm/ by that
repo's make install; make check-installed fails on drift. Config files,
.gate-state and backups stay unversioned.

skills/prod-deploy-via-release-only/SKILL.md (new)

Deployment by release only, with the mechanical guard written as instructions:
restrict the deploy user's elevated access to the release scripts, keep
read-only diagnostics, and check it with sudo -l.

Verification

  • make test: 61 docs scanned, 42 skills well-formed, links resolve, 18 tests OK.
  • migrate-paseo-pins.sh exercised on scratch fixtures: three pin spacings
    rewritten, a two-match file skipped, invalid JSON skipped, and the run exits 1
    while any file still pins the old model.
  • Deployed with make install (symlinks refreshed); restart omp to load the new
    skills — required for any skill change to take effect.

PR Type

Documentation, Enhancement


Description

  • Add litellm-gateway skill with deterministic blue-green runbook

  • Add prod-deploy-via-release-only skill enforcing release-only deployments

  • Both skills were previously unversioned or on unmerged branches


Diagram Walkthrough

flowchart LR
  A["litellm-gateway SKILL.md"] --> B["Blue-green runbook"]
  B --> C["edit blue -> test -> activate -> promote"]
  B --> D["rollback-first, mechanical gates"]
  E["prod-deploy-via-release-only SKILL.md"] --> F["Release process only"]
  F --> G["No ad-hoc production commands"]
  F --> H["Permissions-based enforcement"]
Loading

File Walkthrough

Relevant files
Enhancement
SKILL.md
Add litellm-gateway skill with blue-green runbook               

skills/litellm-gateway/SKILL.md

  • New skill: deterministic blue-green runbook for LiteLLM proxy
  • State table maps live side x difference to exact command
  • Adds promotion step (missing before) to converge config sides
  • Mechanical gates: dry-run by default, refuses editing live side, polls
    health
  • Rollback path shares code with promote, exercised on every promote
+170/-0 
Documentation
SKILL.md
Add prod-deploy-via-release-only skill                                     

skills/prod-deploy-via-release-only/SKILL.md

  • New skill: production changes go only through the defined release
    process
  • Ad-hoc shell commands on production hosts are not a deployment path
  • Mechanical enforcement via permissions (sudoers rule) recommended
  • Guardrails: broken release process is a blocker, not a bypass
    opportunity
+41/-0   

…ash chain)

The skill existed only as an orphan directory under ~/.omp/agent/skills,
never installed from this repo (make install only walks repo skills/*/), so
it could not be reviewed or versioned. Adds it as the canonical source with
the current chain: OpenCode Go deepseek-flash -> Muse -> DeepSeek direct
(z.ai/GLM removed 2026-09-10).

Also records the two rollout traps found while making that change:
- normalize with swap.sh status first: an installed blue side is LIVE, flip
  to green (content-identical) before editing;
- test.sh derives the cascade probe's kill target from the config head's
  api_base, so it cannot pass vacuously after a head change.
Adds the missing promotion step: nothing converged the two sides after a
successful rollover, so green kept an older chain and a later rollback would
silently revert the chain the user had accepted. swap.sh gains promote, which
runs the same test.sh gate, saves the outgoing chain to config.green.yaml.prev,
copies blue over green with a cmp assertion, and returns live to GREEN.

Also makes the gates mechanical rather than remembered:
- activate/promote dry-run by default and print the rollback command; they act
  only with --yes.
- activate refuses when the side being edited is live.
- activate/promote poll health instead of sleeping, then prove the chain with a
  real 'primary' call.
- rollback keeps depending on nothing but the config files and systemd, and
  shares its flip+restart code path with promote, so it is exercised on every
  promote.
…r-rules branch

The skill existed only on docs/skills-over-rules, which never had a PR, so make install left a dangling symlink for it under ~/.omp/agent/skills/. Content unchanged (authored in 54d5d25, 'docs: default to skills over rules; prod-deploy guidance as a skill').

The branch's other commits (semver-release rule, writing-documentation restructure, lucidlint baseline note) overlap the open lucidlint-baseline work and are deliberately not included here.
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 228d274)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Missing post-change steps

The PR adds new skills but does not mention that after merging, make install and omp restart are required for the skills to take effect. Per the review instructions, a change to a skill must include this mention. The PR description should state this explicitly.

---
Missing post-change steps

The PR adds new skills but does not mention that after merging, make install and omp restart are required for the skills to take effect. Per the review instructions, a change to a skill must include this mention. The PR description should state this explicitly.

---
Missing self-check statement

The PR description does not state that the repo self-check (make test) remains green after the change, as required by the review instructions for skill changes. The PR description should explicitly confirm that make test passes.

---
Missing self-check statement

The PR description does not state that the repo self-check (make test) remains green after the change, as required by the review instructions for skill changes. The PR description should explicitly confirm that make test passes.

---
Documentation-standard violation

The skill references external scripts (swap.sh, test.sh, snapshot-live.sh) that are not included in the repo, violating the "Runnable code stays runnable" rule from docs/writing-documentation.md. These scripts must exist in the repo at a resolvable path for the reader to run from checkout.

The tooling is sourced from `ashbywinch/bluegreen-config` and installed into the
state directory by `make install` (copies, not symlinks); `make check-installed`
fails when the installed copy drifts from the repo. Change a script in the repo,
install, then operate.

```
~/.paseo/litellm/
  config.blue.yaml        the chain to edit: `model_list` order (head first) and
                          `fallbacks:` (the order after it), plus the header
                          comments carrying the provider constraints
  config.green.yaml       the rollback target: the chain the last promote left
                          in place
  config.green.yaml.prev  the chain the last promote replaced (one step back)
  config.current.yaml     symlink -> config.green.yaml or config.blue.yaml ;
                          what :4000 serves
  swap.sh                 the state machine — run it with no arguments for the
                          subcommand list; `swap.sh status` prints the live side,
                          whether the sides differ, health, the last gate outcome
                          (.gate-state), and the next command
  test.sh                 the gate — its header documents the probes, the
                          chain-shape contract it parses, and the per-member
                          error/consequence output on FAIL
  snapshot-live.sh        the pre-switch snapshot — prints a timestamped backup
                          of the live side and the exact restore lines
  env                     the API keys (600; never print them)
  .gate-state             the last gate outcome keyed to blue's content hash —
                          status withholds activate/promote while it is FAIL
✅ Resolved findings

skills/litellm-gateway/SKILL.md:1-146

Documentation-standard violation

The skill references external scripts (swap.sh, test.sh) that are not included in the repo, violating the "Runnable code stays runnable" rule from docs/writing-documentation.md. These scripts must exist in the repo at a resolvable path for the reader to run from checkout.

skills/prod-deploy-via-release-only/SKILL.md:1-45

Missing post-change steps

Same as above: the PR description lacks the required mention that make install and omp restart are needed for the new skill to be active.

skills/litellm-gateway/SKILL.md:1

Missing required statement

The PR does not explicitly mention that the change needs make install + omp restart to take effect, as required by the review instructions for skill changes. The PR description should include this statement.

Comment thread skills/litellm-gateway/SKILL.md Resolved
Comment thread skills/litellm-gateway/SKILL.md Resolved
…omotion undo single-use

Review findings: the cascade probe section named the kill target without saying how the script picks it (the first api_base line in the file, patched to an unreachable host), and the promotion undo did not warn that a second promotion overwrites the saved chain.
@github-actions

Copy link
Copy Markdown

Standalone PR Review

PR-Agent could not safely update the persistent review. This standalone result will not replace the canonical review.

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ No major issues detected

Comment thread skills/litellm-gateway/SKILL.md Resolved
…out shell config

Review finding: $EDITOR may be unset, leaving the command to fail silently; ${EDITOR:-vi} keeps the reader's editor when set and still works when it is not.
@github-actions

Copy link
Copy Markdown

Standalone PR Review

PR-Agent could not safely update the persistent review. This standalone result will not replace the canonical review.

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Compliance: PRD

Violation of R5 (AGENTS.md is a bootloader, not an OS): The skill's description and body contain content that is not 100%-relevant to all agents working in the repo. The skill describes a specific operational runbook for a local LiteLLM proxy, which is a domain-specific procedure, not a universal convention. This violates the PRD requirement that AGENTS.md only holds content relevant to 100% of agents. The skill is exempt from AGENTS.md reachability, but the skill's own content should still be focused on its single topic. The skill is well-scoped to one topic (the gateway runbook), so no violation on that front. No other PRD violations found.

---
name: litellm-gateway
description: LiteLLM proxy for Paseo/OMP — deterministic blue-green chain runbook (edit blue -> test -> activate -> promote), rollback-first, one local failover chain
---

# LiteLLM Gateway

One local endpoint (`http://localhost:4000/v1`) owns the model fallback chain.
Clients (omp, Paseo agents) ask for model `primary`; LiteLLM routes through the
chain and fails over per request. Replaces Cloudflare AI Gateway for the text
default; Cloudflare stays configured as the manual rollback.

## The chain (what `primary` means)

`primary` = DeepSeek V4 Flash (OpenCode Go) → Muse Spark (OpenCode) →
DeepSeek V4 Flash (direct DeepSeek API). Chain order and models live in the
LiteLLM config only — clients never change. Editing the chain never touches
omp/Paseo config. (z.ai / GLM was removed from the chain 2026-09-10.)

## Blue-green: the sides and the invariant

~/.paseo/litellm/
config.green.yaml last-known-good chain — the rollback target
config.blue.yaml the ONLY side you edit
config.green.yaml.prev chain replaced at the last promote (one step back)
config.current.yaml symlink -> green|blue ; what :4000 serves
swap.sh status | test | activate --yes | promote --yes | rollback
test.sh validates a config on :4001 (3 probes, no live traffic)
env keys: OPENCODE_API_KEY, DEEPSEEK_API_KEY (600; never print)
(+ ZAI_API_KEY while config.green.yaml.prev still needs it)


INVARIANT: the live side is never the side you edit. Steady state is
`live = GREEN` with `blue == green`; blue is then free staging.

Service: `litellm.service` (systemd --user); `ExecStart` serves
`config.current.yaml`, `EnvironmentFile=~/.paseo/litellm/env`.

## Read the state, then follow the matching row

`~/.paseo/litellm/swap.sh status` prints the live side, whether the sides
differ, health, and the next command. This table is the whole decision
procedure — no judgement calls:

| live side | sides | meaning | next |
|---|---|---|---|
| GREEN | identical | steady state | edit `config.blue.yaml` |
| GREEN | differ | a change is staged, not live | `swap.sh test` → `swap.sh activate --yes` |
| BLUE | differ | new chain live, green = rollback target | soak → `swap.sh promote --yes` (or `swap.sh rollback` to revert) |
| BLUE | identical | green == blue, so live can return to green for free | `swap.sh promote --yes` |
| any | any | `health: NOT HEALTHY` | `swap.sh rollback` |

## The change cycle — exact commands

```bash
cd ~/.paseo/litellm
swap.sh status                 # confirm: live side GREEN, blue free to edit
${EDITOR:-vi} config.blue.yaml  # order, models, keys, timeouts — blue ONLY
swap.sh test                   # gate: 3 probes must PASS (≈20 s, :4001 only)
swap.sh activate               # dry run: prints rollback + plan, switches nothing
swap.sh activate --yes         # re-runs the gate, then live -> blue, then verifies
# ... soak: use the system normally ...
swap.sh promote --yes          # gate, green.prev <- green, green <- blue, live -> green

Expected ends: activate prints served by: <model> naming the upstream that
answered; promote prints promoted: live side GREEN == BLUE.

activate and promote without --yes are dry runs: they print the plan and
the rollback/undo command, then exit 1. The acknowledgment step is mechanical,
not a matter of remembering.

What each part guarantees

  • test.sh — 3 probes on a throwaway instance at :4001, no live traffic:
    primary → 200, streamed primary ends with [DONE], and a copy of the
    config with the head deployment's api_base patched to an unreachable host
    still returns 200 from a fallback. The cascade probe takes the first
    api_base line in the file (that is the head deployment's) and patches it to
    http://127.0.0.1:9/, so it stays a real test when the head changes — it
    cannot pass vacuously.
  • swap.sh activate — refuses when BLUE is already live (the one edit
    blue-green exists to prevent) and refuses unless the gate passes. The flip
    is: symlink, restart, poll /health/readiness (≤120 s), then a real
    primary call. Any failure prints the rollback command.
  • swap.sh promote — refuses unless BLUE is live; runs the same gate; saves
    the outgoing chain to config.green.yaml.prev; copies blue → green and
    asserts cmp equality; then returns live to GREEN (byte-identical content,
    so zero behaviour change). This is what makes rollback content-neutral in
    steady state and frees blue for the next change.
  • swap.sh rollback — the emergency path, depending on NOTHING but the two
    config files and systemd: no provider, no env file, no gate. It flips to
    GREEN, restarts, and waits for health. Run it even if every LLM call is
    failing. rollback and the second half of promote are the same code path,
    so the emergency path is exercised on every promote.

ROLLBACK FIRST — non-negotiable

Before any command that changes the live side, print the rollback command and
make the user acknowledge it. swap.sh prints it itself: on the dry run, on
activate, and on every failure.

~/.paseo/litellm/swap.sh rollback                # first choice, from any terminal
# manual equivalent, if the script itself is broken:
ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm
# undoing a PROMOTION (usable once): the chain the promote replaced
# WARNING: works only once — a second promotion overwrites .prev
cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback
# the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped
# all the way back to Cloudflare (no LiteLLM):
#   ~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Traps (each cost a real incident)

  • NEVER edit the live side — a restart mid-edit serves the half-staged config.
    activate refuses in that state and prints the way out.
  • NEVER promote to make a failure go away: promotion copies the live chain over
    the rollback target.
  • A restart takes tens of seconds; swap.sh polls health for up to 120 s. A
    shell that times out while waiting is not evidence of failure — check
    swap.sh status.
  • test.sh needs the keys in the environment: run it as swap.sh test (which
    sources env), never by hand from a bare shell.

Clients

  • omp/Paseo agents: litellm/primary in modelRoles; litellm provider
    (baseUrl http://localhost:4000/v1) in ~/.omp/agent/models.yml.
    Restart the omp session to pick up provider/model changes.
  • designer/vision roles: still cloudflare-gateway/dynamic/image
    (Cloudflare image route, free of the text-chain).
  • PR-Agent (GitHub Actions): stays on Cloudflare — it cannot reach
    localhost.

Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in ~/.paseo/agents/<worktree>/<agent>.json
(config.model, duplicated in runtimeInfo.model and
persistence.metadata.model). Those pins OVERRIDE modelRoles.default:
sessions created before the migration keep
cloudflare-gateway/dynamic/fallback2 no matter how often the daemon
restarts. Migration = rewrite the pins:

  1. systemctl --user stop paseo FIRST — editing under a live daemon loses
    the race: dirty in-memory session state flushes back on shutdown and
    reverts the edit (observed: 4 of 21 pins reverted through a live edit).

  2. Rewrite pins with an exact match (spares the historical lastError
    strings, which reference the old model with model= syntax):

    grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
      ~/.paseo/agents --include='*.json' | xargs sed -i \
      's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
  3. systemctl --user start paseo, then re-grep: expect 0 hits.

New sessions follow agents.providers.omp.models[].isDefault in
~/.paseo/config.json; the per-session pin is what the Paseo UI model
dropdown sets.

Diagnostics

  • Provider-side errors — OpenCode DataPolicyError (workspace opt-in),
    MonthlyLimitError (OpenCode spend cap) — meanings and fixes are canonical
    in skill://cloudflare-ai-gateway/references/adding-providers.md.
  • Keys live in ~/.paseo/litellm/env (600); never print them.

</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-038a320170778a71d630f86e75e89d1c293ab409207dcfdc9bf92b56d3bcb158R118-R128'><strong>Compliance: writing-documentation</strong></a>

Violation of 'No lectures on history' rule: Lines 118-128 contain a 'Traps' section that explains past incidents ("each cost a real incident"). This is a lecture on history — it describes what went wrong in the past rather than stating the constraint directly. The rule says: "Never explain why a decision was made by describing the alternative that failed and the person who made it." The traps should be rewritten as explicit negatives ("NEVER edit the live side — a restart mid-edit serves the half-staged config") without the historical framing. The first trap is already well-stated as a negative; the historical note is the violation.
</summary>

```markdown
## Traps (each cost a real incident)

- NEVER edit the live side — a restart mid-edit serves the half-staged config.
  `activate` refuses in that state and prints the way out.
- NEVER promote to make a failure go away: promotion copies the live chain over
  the rollback target.
- A restart takes tens of seconds; `swap.sh` polls health for up to 120 s. A
  shell that times out while waiting is not evidence of failure — check
  `swap.sh status`.
- `test.sh` needs the keys in the environment: run it as `swap.sh test` (which
  sources `env`), never by hand from a bare shell.
Compliance: writing-documentation

Violation of 'No lectures on history' rule: Line 151 contains "observed: 4 of 21 pins reverted through a live edit". This is a historical anecdote describing a past failure. The rule requires stating the constraint directly: "NEVER edit under a live daemon — dirty in-memory session state flushes back on shutdown and reverts the edit." The parenthetical observation is a lecture on history.

reverts the edit (observed: 4 of 21 pins reverted through a live edit).
Compliance: writing-documentation

Violation of 'Rules as explicit negatives' rule: Line 120 says "NEVER edit the live side" which is good, but line 121 says "activate refuses in that state and prints the way out" which describes the mechanism rather than stating the rule as a negative. The rule should be the primary instruction; the mechanism is implementation detail. This is a minor violation — the negative rule is present, but the mechanism description dilutes it.

- NEVER edit the live side — a restart mid-edit serves the half-staged config.
  `activate` refuses in that state and prints the way out.
Compliance: writing-documentation

Violation of 'One topic per file' rule: The skill covers the LiteLLM gateway runbook (its primary topic) but also includes a detailed migration procedure for Paseo session pins (lines 140-165). This is a second, distinct procedure (migrating session model pins) that belongs in a separate skill or doc. The migration procedure has its own audience (agents managing Paseo sessions) and its own topic (rewriting model pins), which violates the one-topic-per-file rule. It should be split into a separate skill or doc and linked.

### Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in `~/.paseo/agents/<worktree>/<agent>.json`
(`config.model`, duplicated in `runtimeInfo.model` and
`persistence.metadata.model`). Those pins OVERRIDE `modelRoles.default`:
sessions created before the migration keep
`cloudflare-gateway/dynamic/fallback2` no matter how often the daemon
restarts. Migration = rewrite the pins:

1. `systemctl --user stop paseo` FIRST — editing under a live daemon loses
   the race: dirty in-memory session state flushes back on shutdown and
   reverts the edit (observed: 4 of 21 pins reverted through a live edit).
2. Rewrite pins with an exact match (spares the historical `lastError`
   strings, which reference the old model with `model=` syntax):

   ```bash
   grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
     ~/.paseo/agents --include='*.json' | xargs sed -i \
     's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
  1. systemctl --user start paseo, then re-grep: expect 0 hits.

New sessions follow agents.providers.omp.models[].isDefault in
~/.paseo/config.json; the per-session pin is what the Paseo UI model
dropdown sets.


</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-038a320170778a71d630f86e75e89d1c293ab409207dcfdc9bf92b56d3bcb158R155-R158'><strong>Compliance: writing-documentation</strong></a>

Violation of 'Density & concision — Code in code files, not inline in docs' rule: Lines 155-158 contain a multi-line bash script (grep + sed + xargs pipeline) inline in the doc. This is a runnable script that should live in a separate file (e.g., `skills/litellm-gateway/examples/migrate-pins.sh`) and be referenced by link. The doc says "Rewrite pins with an exact match" and then shows the full command — this duplicates the code's job and creates a maintenance burden (the script cannot be linted or tested inline).
</summary>

```markdown
```bash
grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
  ~/.paseo/agents --include='*.json' | xargs sed -i \
  's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
Compliance: writing-documentation

Violation of 'Density & concision — Runnable code stays runnable' rule: The inline bash script on lines 155-158 is not in a file that CI can exercise. It references ~/.paseo/agents paths that are machine-specific and not part of the repo. The rule requires that "a script or sample a doc references must exist in the repo at a resolvable path the reader can run from their checkout, and should be exercised by CI so it doesn't rot." This script cannot be run from the checkout and is not exercised by CI.

```bash
grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
  ~/.paseo/agents --include='*.json' | xargs sed -i \
  's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
Compliance: writing-documentation

Violation of 'Density & concision — Never reference machine-specific paths' rule: Lines 23-31, 42, 57, 105-116, 132-133, 142, 149, 157, 163, 171 contain references to ~/.paseo/, ~/.omp/, and /home/<user>/-equivalent paths. While ~/.paseo/ and ~/.omp/ are conventions (not user-specific paths), the rule says "use repo-relative paths or a generic convention when the location is a convention, not a user folder." These are conventions, so this is not a violation — the paths are generic conventions, not machine-specific. No violation found.

~/.paseo/litellm/
  config.green.yaml       last-known-good chain — the rollback target
  config.blue.yaml        the ONLY side you edit
  config.green.yaml.prev  chain replaced at the last promote (one step back)
  config.current.yaml     symlink -> green|blue ; what :4000 serves
  swap.sh                 status | test | activate --yes | promote --yes | rollback
  test.sh                 validates a config on :4001 (3 probes, no live traffic)
  env                     keys: OPENCODE_API_KEY, DEEPSEEK_API_KEY (600; never print)
                          (+ ZAI_API_KEY while config.green.yaml.prev still needs it)

INVARIANT: the live side is never the side you edit. Steady state is
live = GREEN with blue == green; blue is then free staging.

Service: litellm.service (systemd --user); ExecStart serves
config.current.yaml, EnvironmentFile=~/.paseo/litellm/env.

Read the state, then follow the matching row

~/.paseo/litellm/swap.sh status prints the live side, whether the sides
differ, health, and the next command. This table is the whole decision
procedure — no judgement calls:

live side sides meaning next
GREEN identical steady state edit config.blue.yaml
GREEN differ a change is staged, not live swap.sh test → swap.sh activate --yes
BLUE differ new chain live, green = rollback target soak → swap.sh promote --yes (or swap.sh rollback to revert)
BLUE identical green == blue, so live can return to green for free swap.sh promote --yes
any any health: NOT HEALTHY swap.sh rollback

The change cycle — exact commands

cd ~/.paseo/litellm
swap.sh status                 # confirm: live side GREEN, blue free to edit
${EDITOR:-vi} config.blue.yaml  # order, models, keys, timeouts — blue ONLY
swap.sh test                   # gate: 3 probes must PASS (≈20 s, :4001 only)
swap.sh activate               # dry run: prints rollback + plan, switches nothing
swap.sh activate --yes         # re-runs the gate, then live -> blue, then verifies
# ... soak: use the system normally ...
swap.sh promote --yes          # gate, green.prev <- green, green <- blue, live -> green

Expected ends: activate prints served by: <model> naming the upstream that
answered; promote prints promoted: live side GREEN == BLUE.

activate and promote without --yes are dry runs: they print the plan and
the rollback/undo command, then exit 1. The acknowledgment step is mechanical,
not a matter of remembering.

What each part guarantees

  • test.sh — 3 probes on a throwaway instance at :4001, no live traffic:
    primary → 200, streamed primary ends with [DONE], and a copy of the
    config with the head deployment's api_base patched to an unreachable host
    still returns 200 from a fallback. The cascade probe takes the first
    api_base line in the file (that is the head deployment's) and patches it to
    http://127.0.0.1:9/, so it stays a real test when the head changes — it
    cannot pass vacuously.
  • swap.sh activate — refuses when BLUE is already live (the one edit
    blue-green exists to prevent) and refuses unless the gate passes. The flip
    is: symlink, restart, poll /health/readiness (≤120 s), then a real
    primary call. Any failure prints the rollback command.
  • swap.sh promote — refuses unless BLUE is live; runs the same gate; saves
    the outgoing chain to config.green.yaml.prev; copies blue → green and
    asserts cmp equality; then returns live to GREEN (byte-identical content,
    so zero behaviour change). This is what makes rollback content-neutral in
    steady state and frees blue for the next change.
  • swap.sh rollback — the emergency path, depending on NOTHING but the two
    config files and systemd: no provider, no env file, no gate. It flips to
    GREEN, restarts, and waits for health. Run it even if every LLM call is
    failing. rollback and the second half of promote are the same code path,
    so the emergency path is exercised on every promote.

ROLLBACK FIRST — non-negotiable

Before any command that changes the live side, print the rollback command and
make the user acknowledge it. swap.sh prints it itself: on the dry run, on
activate, and on every failure.

~/.paseo/litellm/swap.sh rollback                # first choice, from any terminal
# manual equivalent, if the script itself is broken:
ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm
# undoing a PROMOTION (usable once): the chain the promote replaced
# WARNING: works only once — a second promotion overwrites .prev
cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback
# the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped
# all the way back to Cloudflare (no LiteLLM):
#   ~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Traps (each cost a real incident)

  • NEVER edit the live side — a restart mid-edit serves the half-staged config.
    activate refuses in that state and prints the way out.
  • NEVER promote to make a failure go away: promotion copies the live chain over
    the rollback target.
  • A restart takes tens of seconds; swap.sh polls health for up to 120 s. A
    shell that times out while waiting is not evidence of failure — check
    swap.sh status.
  • test.sh needs the keys in the environment: run it as swap.sh test (which
    sources env), never by hand from a bare shell.

Clients

  • omp/Paseo agents: litellm/primary in modelRoles; litellm provider
    (baseUrl http://localhost:4000/v1) in ~/.omp/agent/models.yml.
    Restart the omp session to pick up provider/model changes.
  • designer/vision roles: still cloudflare-gateway/dynamic/image
    (Cloudflare image route, free of the text-chain).
  • PR-Agent (GitHub Actions): stays on Cloudflare — it cannot reach
    localhost.

Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in ~/.paseo/agents/<worktree>/<agent>.json
(config.model, duplicated in runtimeInfo.model and
persistence.metadata.model). Those pins OVERRIDE modelRoles.default:
sessions created before the migration keep
cloudflare-gateway/dynamic/fallback2 no matter how often the daemon
restarts. Migration = rewrite the pins:

  1. systemctl --user stop paseo FIRST — editing under a live daemon loses
    the race: dirty in-memory session state flushes back on shutdown and
    reverts the edit (observed: 4 of 21 pins reverted through a live edit).

  2. Rewrite pins with an exact match (spares the historical lastError
    strings, which reference the old model with model= syntax):

    grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
      ~/.paseo/agents --include='*.json' | xargs sed -i \
      's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
  3. systemctl --user start paseo, then re-grep: expect 0 hits.

New sessions follow agents.providers.omp.models[].isDefault in
~/.paseo/config.json; the per-session pin is what the Paseo UI model
dropdown sets.

Diagnostics

  • Provider-side errors — OpenCode DataPolicyError (workspace opt-in),
    MonthlyLimitError (OpenCode spend cap) — meanings and fixes are canonical
    in skill://cloudflare-ai-gateway/references/adding-providers.md.

</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-038a320170778a71d630f86e75e89d1c293ab409207dcfdc9bf92b56d3bcb158R1-R172'><strong>Compliance: documentation-structure</strong></a>

No violations. The skill is a new file in the `skills/` directory, which is exempt from the AGENTS.md reachability rule per docs/documentation-structure.md ("Skills are exempt from the AGENTS.md reachability rule"). The skill does not create a new required doc (PRD, TECHSPEC, UX, PLAN) — it is a skill, not a project doc. The skill's structure (frontmatter with name/description, then body) follows the expected skill format. No violations found.
</summary>

```markdown
---
name: litellm-gateway
description: LiteLLM proxy for Paseo/OMP — deterministic blue-green chain runbook (edit blue -> test -> activate -> promote), rollback-first, one local failover chain
---

# LiteLLM Gateway

One local endpoint (`http://localhost:4000/v1`) owns the model fallback chain.
Clients (omp, Paseo agents) ask for model `primary`; LiteLLM routes through the
chain and fails over per request. Replaces Cloudflare AI Gateway for the text
default; Cloudflare stays configured as the manual rollback.

## The chain (what `primary` means)

`primary` = DeepSeek V4 Flash (OpenCode Go) → Muse Spark (OpenCode) →
DeepSeek V4 Flash (direct DeepSeek API). Chain order and models live in the
LiteLLM config only — clients never change. Editing the chain never touches
omp/Paseo config. (z.ai / GLM was removed from the chain 2026-09-10.)

## Blue-green: the sides and the invariant

~/.paseo/litellm/
config.green.yaml last-known-good chain — the rollback target
config.blue.yaml the ONLY side you edit
config.green.yaml.prev chain replaced at the last promote (one step back)
config.current.yaml symlink -> green|blue ; what :4000 serves
swap.sh status | test | activate --yes | promote --yes | rollback
test.sh validates a config on :4001 (3 probes, no live traffic)
env keys: OPENCODE_API_KEY, DEEPSEEK_API_KEY (600; never print)
(+ ZAI_API_KEY while config.green.yaml.prev still needs it)


INVARIANT: the live side is never the side you edit. Steady state is
`live = GREEN` with `blue == green`; blue is then free staging.

Service: `litellm.service` (systemd --user); `ExecStart` serves
`config.current.yaml`, `EnvironmentFile=~/.paseo/litellm/env`.

## Read the state, then follow the matching row

`~/.paseo/litellm/swap.sh status` prints the live side, whether the sides
differ, health, and the next command. This table is the whole decision
procedure — no judgement calls:

| live side | sides | meaning | next |
|---|---|---|---|
| GREEN | identical | steady state | edit `config.blue.yaml` |
| GREEN | differ | a change is staged, not live | `swap.sh test` → `swap.sh activate --yes` |
| BLUE | differ | new chain live, green = rollback target | soak → `swap.sh promote --yes` (or `swap.sh rollback` to revert) |
| BLUE | identical | green == blue, so live can return to green for free | `swap.sh promote --yes` |
| any | any | `health: NOT HEALTHY` | `swap.sh rollback` |

## The change cycle — exact commands

```bash
cd ~/.paseo/litellm
swap.sh status                 # confirm: live side GREEN, blue free to edit
${EDITOR:-vi} config.blue.yaml  # order, models, keys, timeouts — blue ONLY
swap.sh test                   # gate: 3 probes must PASS (≈20 s, :4001 only)
swap.sh activate               # dry run: prints rollback + plan, switches nothing
swap.sh activate --yes         # re-runs the gate, then live -> blue, then verifies
# ... soak: use the system normally ...
swap.sh promote --yes          # gate, green.prev <- green, green <- blue, live -> green

Expected ends: activate prints served by: <model> naming the upstream that
answered; promote prints promoted: live side GREEN == BLUE.

activate and promote without --yes are dry runs: they print the plan and
the rollback/undo command, then exit 1. The acknowledgment step is mechanical,
not a matter of remembering.

What each part guarantees

  • test.sh — 3 probes on a throwaway instance at :4001, no live traffic:
    primary → 200, streamed primary ends with [DONE], and a copy of the
    config with the head deployment's api_base patched to an unreachable host
    still returns 200 from a fallback. The cascade probe takes the first
    api_base line in the file (that is the head deployment's) and patches it to
    http://127.0.0.1:9/, so it stays a real test when the head changes — it
    cannot pass vacuously.
  • swap.sh activate — refuses when BLUE is already live (the one edit
    blue-green exists to prevent) and refuses unless the gate passes. The flip
    is: symlink, restart, poll /health/readiness (≤120 s), then a real
    primary call. Any failure prints the rollback command.
  • swap.sh promote — refuses unless BLUE is live; runs the same gate; saves
    the outgoing chain to config.green.yaml.prev; copies blue → green and
    asserts cmp equality; then returns live to GREEN (byte-identical content,
    so zero behaviour change). This is what makes rollback content-neutral in
    steady state and frees blue for the next change.
  • swap.sh rollback — the emergency path, depending on NOTHING but the two
    config files and systemd: no provider, no env file, no gate. It flips to
    GREEN, restarts, and waits for health. Run it even if every LLM call is
    failing. rollback and the second half of promote are the same code path,
    so the emergency path is exercised on every promote.

ROLLBACK FIRST — non-negotiable

Before any command that changes the live side, print the rollback command and
make the user acknowledge it. swap.sh prints it itself: on the dry run, on
activate, and on every failure.

~/.paseo/litellm/swap.sh rollback                # first choice, from any terminal
# manual equivalent, if the script itself is broken:
ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm
# undoing a PROMOTION (usable once): the chain the promote replaced
# WARNING: works only once — a second promotion overwrites .prev
cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback
# the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped
# all the way back to Cloudflare (no LiteLLM):
#   ~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Traps (each cost a real incident)

  • NEVER edit the live side — a restart mid-edit serves the half-staged config.
    activate refuses in that state and prints the way out.
  • NEVER promote to make a failure go away: promotion copies the live chain over
    the rollback target.
  • A restart takes tens of seconds; swap.sh polls health for up to 120 s. A
    shell that times out while waiting is not evidence of failure — check
    swap.sh status.
  • test.sh needs the keys in the environment: run it as swap.sh test (which
    sources env), never by hand from a bare shell.

Clients

  • omp/Paseo agents: litellm/primary in modelRoles; litellm provider
    (baseUrl http://localhost:4000/v1) in ~/.omp/agent/models.yml.
    Restart the omp session to pick up provider/model changes.
  • designer/vision roles: still cloudflare-gateway/dynamic/image
    (Cloudflare image route, free of the text-chain).
  • PR-Agent (GitHub Actions): stays on Cloudflare — it cannot reach
    localhost.

Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in ~/.paseo/agents/<worktree>/<agent>.json
(config.model, duplicated in runtimeInfo.model and
persistence.metadata.model). Those pins OVERRIDE modelRoles.default:
sessions created before the migration keep
cloudflare-gateway/dynamic/fallback2 no matter how often the daemon
restarts. Migration = rewrite the pins:

  1. systemctl --user stop paseo FIRST — editing under a live daemon loses
    the race: dirty in-memory session state flushes back on shutdown and
    reverts the edit (observed: 4 of 21 pins reverted through a live edit).

  2. Rewrite pins with an exact match (spares the historical lastError
    strings, which reference the old model with model= syntax):

    grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
      ~/.paseo/agents --include='*.json' | xargs sed -i \
      's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
  3. systemctl --user start paseo, then re-grep: expect 0 hits.

New sessions follow agents.providers.omp.models[].isDefault in
~/.paseo/config.json; the per-session pin is what the Paseo UI model
dropdown sets.

Diagnostics

  • Provider-side errors — OpenCode DataPolicyError (workspace opt-in),
    MonthlyLimitError (OpenCode spend cap) — meanings and fixes are canonical
    in skill://cloudflare-ai-gateway/references/adding-providers.md.
  • Keys live in ~/.paseo/litellm/env (600); never print them.

</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-f23807a6b61011bbb185c47c8be435b9c277ac7e1233937fe301e133254d0c30R1-R41'><strong>Compliance: PRD</strong></a>

No violations. The skill is well-scoped to one topic (production deploy discipline), uses explicit negatives ("never a deployment path", "never to bypass it"), and is a skill (exempt from AGENTS.md reachability). It does not violate any PRD requirement.
</summary>

```markdown
---
name: prod-deploy-via-release-only
description: Production changes go ONLY through the project's release process; ad-hoc commands on a host are never a deployment path. If the process is too slow, improve it — never bypass it.
---

# Production deploy via the release process only

A production change — code, schema, config, restart — is deployed through
the project's **defined release process** (its CI/release workflow, or the
release scripts that workflow invokes). Ad-hoc shell commands on a
production host (pull + restart, direct config edits, manual restarts) are
**not** a deployment path, however urgent the fix feels. If the release
process is too slow or awkward to use, the answer is to improve the
process, never to bypass it.

## Why

The release process exists to make production changes reviewable, tested
(standby + smoke before switch), and reversible (rollback). Every ad-hoc
bypass erodes that: an unreviewed change ships, the smoke gate is skipped,
rollback is impossible, and the next person (or agent) follows the
precedent.

## Mechanical enforcement, not just documentation

A rule that only says "don't" gets broken under pressure. The durable
guard is permissions: on the production host, the deploy user's elevated
access is restricted to exactly the release scripts (a sudoers rule, or
equivalent); no interactive login can restart the app units or mutate the
deployment directly. Then a direct deployment is not merely discouraged —
it is impossible. Read-only diagnostics (logs, status) stay available.

## Guardrails

- Deploy via the workflow/release script, or don't deploy.
- A broken release process is a blocker to fix; skipping it is never the fix.
- Host permissions make direct mutation impossible; verify the guard works
  (attempt the blocked operation and confirm it fails).
- One-time infrastructure hardening (permissions, keys) that makes the
  guard real is legitimate — do it deliberately, verify it, and record it
  in the reproducible setup so fresh hosts get the guard automatically.
Compliance: writing-documentation

Violation of 'Standards pair with their enforcement' rule: The skill states a rule ("Production changes go ONLY through the project's release process") and describes mechanical enforcement (permissions, sudoers rules), but does not name a specific enforcement mechanism that is checkable. The skill says "Host permissions make direct mutation impossible" and "verify the guard works" — but these are aspirational, not a concrete, checkable enforcement. The rule requires: "a standard with no skill or check behind it is a finding at review." This skill is the standard itself, but it does not link to or define a check that verifies the guard is in place. It should reference a test or architecture test that confirms the guard.

## Mechanical enforcement, not just documentation

A rule that only says "don't" gets broken under pressure. The durable
guard is permissions: on the production host, the deploy user's elevated
access is restricted to exactly the release scripts (a sudoers rule, or
equivalent); no interactive login can restart the app units or mutate the
deployment directly. Then a direct deployment is not merely discouraged —
it is impossible. Read-only diagnostics (logs, status) stay available.
Compliance: writing-documentation

Violation of 'Density & concision — Rules as explicit negatives' rule: Line 8 says "A production change ... is deployed through the project's defined release process." This is a positive statement. The rule requires "Never X" format for instructions an agent must act on. The skill does use negatives in the description ("never a deployment path", "never to bypass it") but the main rule body is stated positively. This is a minor violation — the negative form is present in the frontmatter description but not in the body.

A production change — code, schema, config, restart — is deployed through
the project's **defined release process** (its CI/release workflow, or the
release scripts that workflow invokes). Ad-hoc shell commands on a
production host (pull + restart, direct config edits, manual restarts) are
**not** a deployment path, however urgent the fix feels. If the release
process is too slow or awkward to use, the answer is to improve the
process, never to bypass it.
Compliance: documentation-structure

No violations. The skill is a new file in the skills/ directory, exempt from AGENTS.md reachability. It does not create a new required doc. No violations found.

---
name: prod-deploy-via-release-only
description: Production changes go ONLY through the project's release process; ad-hoc commands on a host are never a deployment path. If the process is too slow, improve it — never bypass it.
---

# Production deploy via the release process only

A production change — code, schema, config, restart — is deployed through
the project's **defined release process** (its CI/release workflow, or the
release scripts that workflow invokes). Ad-hoc shell commands on a
production host (pull + restart, direct config edits, manual restarts) are
**not** a deployment path, however urgent the fix feels. If the release
process is too slow or awkward to use, the answer is to improve the
process, never to bypass it.

## Why

The release process exists to make production changes reviewable, tested
(standby + smoke before switch), and reversible (rollback). Every ad-hoc
bypass erodes that: an unreviewed change ships, the smoke gate is skipped,
rollback is impossible, and the next person (or agent) follows the
precedent.

## Mechanical enforcement, not just documentation

A rule that only says "don't" gets broken under pressure. The durable
guard is permissions: on the production host, the deploy user's elevated
access is restricted to exactly the release scripts (a sudoers rule, or
equivalent); no interactive login can restart the app units or mutate the
deployment directly. Then a direct deployment is not merely discouraged —
it is impossible. Read-only diagnostics (logs, status) stay available.

## Guardrails

- Deploy via the workflow/release script, or don't deploy.
- A broken release process is a blocker to fix; skipping it is never the fix.
- Host permissions make direct mutation impossible; verify the guard works
  (attempt the blocked operation and confirm it fails).
- One-time infrastructure hardening (permissions, keys) that makes the
  guard real is legitimate — do it deliberately, verify it, and record it
  in the reproducible setup so fresh hosts get the guard automatically.

Comment thread skills/litellm-gateway/SKILL.md Resolved
Comment thread skills/litellm-gateway/SKILL.md Resolved
…nce check

Three review findings: the live-side trap now says what actually guards it (a symlink cannot be locked by permissions), the Diagnostics section no longer points at references/adding-providers.md, which does not exist in the repo, and prod-deploy gains a checkable acceptance criterion for its permissions guard.
@github-actions

Copy link
Copy Markdown

Standalone PR Review

PR-Agent could not safely update the persistent review. This standalone result will not replace the canonical review.

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ No major issues detected

Comment thread skills/litellm-gateway/SKILL.md Resolved
Comment thread skills/litellm-gateway/SKILL.md Resolved
…e cost concretely

Final review round: the live-side trap no longer leans on a symlink-permission claim (the real point is that nothing on the filesystem stops the write), and the promote trap names the cost — green overwritten with the bad chain, rollback gone, recovery only via the one-step .prev restore or the manual Cloudflare switch.
@github-actions

Copy link
Copy Markdown

Standalone PR Review

PR-Agent could not safely update the persistent review. This standalone result will not replace the canonical review.

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ No major issues detected

Comment thread skills/litellm-gateway/SKILL.md Resolved
…grep matches nothing

GNU xargs runs the command once with no operands when the input is empty, which hands sed a stdin read with no files; -r makes the zero-match case an honest no-op. The step already says to expect 0 hits afterwards, so the empty case is a real one.
@github-actions

Copy link
Copy Markdown

Standalone PR Review

PR-Agent could not safely update the persistent review. This standalone result will not replace the canonical review.

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Compliance — PRD: missing requirement for bot enforcement

The skill describes operational procedures (swap.sh, test.sh, activate, promote) but nowhere mentions that the review bot enforces the rules (PRD R1–R3). The PRD requires that standards are materialized into repos, checkable, and that the bot sees all relevant docs. This skill is an operational runbook — it should state which parts are bot-enforced or how the bot checks adherence to the blue-green invariant. Without that, the skill is a wish (per docs/writing-documentation.md: "a standard nothing enforces is a wish").

---
name: litellm-gateway
description: LiteLLM proxy for Paseo/OMP — deterministic blue-green chain runbook (edit blue -> test -> activate -> promote), rollback-first, one local failover chain
---

# LiteLLM Gateway

One local endpoint (`http://localhost:4000/v1`) owns the model fallback chain.
Clients (omp, Paseo agents) ask for model `primary`; LiteLLM routes through the
chain and fails over per request. Replaces Cloudflare AI Gateway for the text
default; Cloudflare stays configured as the manual rollback.

## The chain (what `primary` means)

`primary` = DeepSeek V4 Flash (OpenCode Go) → Muse Spark (OpenCode) →
DeepSeek V4 Flash (direct DeepSeek API). Chain order and models live in the
LiteLLM config only — clients never change. Editing the chain never touches
omp/Paseo config. (z.ai / GLM was removed from the chain 2026-09-10.)

## Blue-green: the sides and the invariant

~/.paseo/litellm/
config.green.yaml last-known-good chain — the rollback target
config.blue.yaml the ONLY side you edit
config.green.yaml.prev chain replaced at the last promote (one step back)
config.current.yaml symlink -> green|blue ; what :4000 serves
swap.sh status | test | activate --yes | promote --yes | rollback
test.sh validates a config on :4001 (3 probes, no live traffic)
env keys: OPENCODE_API_KEY, DEEPSEEK_API_KEY (600; never print)
(+ ZAI_API_KEY while config.green.yaml.prev still needs it)


INVARIANT: the live side is never the side you edit. Steady state is
`live = GREEN` with `blue == green`; blue is then free staging.

Service: `litellm.service` (systemd --user); `ExecStart` serves
`config.current.yaml`, `EnvironmentFile=~/.paseo/litellm/env`.

## Read the state, then follow the matching row

`~/.paseo/litellm/swap.sh status` prints the live side, whether the sides
differ, health, and the next command. This table is the whole decision
procedure — no judgement calls:

| live side | sides | meaning | next |
|---|---|---|---|
| GREEN | identical | steady state | edit `config.blue.yaml` |
| GREEN | differ | a change is staged, not live | `swap.sh test` → `swap.sh activate --yes` |
| BLUE | differ | new chain live, green = rollback target | soak → `swap.sh promote --yes` (or `swap.sh rollback` to revert) |
| BLUE | identical | green == blue, so live can return to green for free | `swap.sh promote --yes` |
| any | any | `health: NOT HEALTHY` | `swap.sh rollback` |

## The change cycle — exact commands

```bash
cd ~/.paseo/litellm
swap.sh status                 # confirm: live side GREEN, blue free to edit
${EDITOR:-vi} config.blue.yaml  # order, models, keys, timeouts — blue ONLY
swap.sh test                   # gate: 3 probes must PASS (≈20 s, :4001 only)
swap.sh activate               # dry run: prints rollback + plan, switches nothing
swap.sh activate --yes         # re-runs the gate, then live -> blue, then verifies
# ... soak: use the system normally ...
swap.sh promote --yes          # gate, green.prev <- green, green <- blue, live -> green

Expected ends: activate prints served by: <model> naming the upstream that
answered; promote prints promoted: live side GREEN == BLUE.

activate and promote without --yes are dry runs: they print the plan and
the rollback/undo command, then exit 1. The acknowledgment step is mechanical,
not a matter of remembering.

What each part guarantees

  • test.sh — 3 probes on a throwaway instance at :4001, no live traffic:
    primary → 200, streamed primary ends with [DONE], and a copy of the
    config with the head deployment's api_base patched to an unreachable host
    still returns 200 from a fallback. The cascade probe takes the first
    api_base line in the file (that is the head deployment's) and patches it to
    http://127.0.0.1:9/, so it stays a real test when the head changes — it
    cannot pass vacuously.
  • swap.sh activate — refuses when BLUE is already live (the one edit
    blue-green exists to prevent) and refuses unless the gate passes. The flip
    is: symlink, restart, poll /health/readiness (≤120 s), then a real
    primary call. Any failure prints the rollback command.
  • swap.sh promote — refuses unless BLUE is live; runs the same gate; saves
    the outgoing chain to config.green.yaml.prev; copies blue → green and
    asserts cmp equality; then returns live to GREEN (byte-identical content,
    so zero behaviour change). This is what makes rollback content-neutral in
    steady state and frees blue for the next change.
  • swap.sh rollback — the emergency path, depending on NOTHING but the two
    config files and systemd: no provider, no env file, no gate. It flips to
    GREEN, restarts, and waits for health. Run it even if every LLM call is
    failing. rollback and the second half of promote are the same code path,
    so the emergency path is exercised on every promote.

ROLLBACK FIRST — non-negotiable

Before any command that changes the live side, print the rollback command and
make the user acknowledge it. swap.sh prints it itself: on the dry run, on
activate, and on every failure.

~/.paseo/litellm/swap.sh rollback                # first choice, from any terminal
# manual equivalent, if the script itself is broken:
ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm
# undoing a PROMOTION (usable once): the chain the promote replaced
# WARNING: works only once — a second promotion overwrites .prev
cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback
# the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped
# all the way back to Cloudflare (no LiteLLM):
#   ~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Traps (each cost a real incident)

  • NEVER edit the live side — a restart mid-edit serves the half-staged config.
    The guard is the check, not a lock: config.current.yaml is a symlink and
    the filesystem does not stop you writing to the live side's config file.
    Always run swap.sh status first and edit only the side it does not name as
    live. activate refuses to make the side you are editing the live one.
  • NEVER promote to make a failure go away: promotion runs the same gate, but it
    is still the step that copies the live chain over the rollback target. If what
    you are working around is something the gate cannot see, green is overwritten
    with the bad chain — rollback no longer reverts it, and the ways back are the
    one-step .prev restore (until the next promote) or the manual Cloudflare
    switch in ~/.omp/agent/config.yml.
  • A restart takes tens of seconds; swap.sh polls health for up to 120 s. A
    shell that times out while waiting is not evidence of failure — check
    swap.sh status.
  • test.sh needs the keys in the environment: run it as swap.sh test (which
    sources env), never by hand from a bare shell.

Clients

  • omp/Paseo agents: litellm/primary in modelRoles; litellm provider
    (baseUrl http://localhost:4000/v1) in ~/.omp/agent/models.yml.
    Restart the omp session to pick up provider/model changes.
  • designer/vision roles: still cloudflare-gateway/dynamic/image
    (Cloudflare image route, free of the text-chain).
  • PR-Agent (GitHub Actions): stays on Cloudflare — it cannot reach
    localhost.

Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in ~/.paseo/agents/<worktree>/<agent>.json
(config.model, duplicated in runtimeInfo.model and
persistence.metadata.model). Those pins OVERRIDE modelRoles.default:
sessions created before the migration keep
cloudflare-gateway/dynamic/fallback2 no matter how often the daemon
restarts. Migration = rewrite the pins:

  1. systemctl --user stop paseo FIRST — editing under a live daemon loses
    the race: dirty in-memory session state flushes back on shutdown and
    reverts the edit (observed: 4 of 21 pins reverted through a live edit).

  2. Rewrite pins with an exact match (spares the historical lastError
    strings, which reference the old model with model= syntax):

    grep -rl '"model": "cloudflare-gateway/dynamic/fallback2"' \
      ~/.paseo/agents --include='*.json' | xargs -r sed -i \
      's/"model": "cloudflare-gateway\/dynamic\/fallback2"/"model": "litellm\/primary"/g'
  3. systemctl --user start paseo, then re-grep: expect 0 hits.

New sessions follow agents.providers.omp.models[].isDefault in
~/.paseo/config.json; the per-session pin is what the Paseo UI model
dropdown sets.

Diagnostics

  • OpenCode errors: DataPolicyError = the workspace has not opted in to its
    data policy; MonthlyLimitError = the OpenCode spend cap is reached. Both
    are fixed on the OpenCode side, not in this chain's config.
  • Provider configuration for these routes (Cloudflare, OpenCode, DeepSeek) is
    in skill://cloudflare-ai-gateway.
  • Keys live in ~/.paseo/litellm/env (600); never print them.

</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-038a320170778a71d630f86e75e89d1c293ab409207dcfdc9bf92b56d3bcb158R1-R19'><strong>Compliance — documentation standard: duplicated chain description</strong></a>

The chain definition ("DeepSeek V4 Flash → Muse Spark → DeepSeek V4 Flash") appears both in the file header (lines 1–3 description field) and again in the "The chain" section (lines 13–18). The description in the front matter repeats the full chain; the section then restates it. Per docs/writing-documentation.md, "each piece of information lives in exactly one place." Move the chain definition out of the description field, or make the description a brief pointer and keep the full chain in the section.
</summary>

```markdown
---
name: litellm-gateway
description: LiteLLM proxy for Paseo/OMP — deterministic blue-green chain runbook (edit blue -> test -> activate -> promote), rollback-first, one local failover chain
---

# LiteLLM Gateway

One local endpoint (`http://localhost:4000/v1`) owns the model fallback chain.
Clients (omp, Paseo agents) ask for model `primary`; LiteLLM routes through the
chain and fails over per request. Replaces Cloudflare AI Gateway for the text
default; Cloudflare stays configured as the manual rollback.

## The chain (what `primary` means)

`primary` = DeepSeek V4 Flash (OpenCode Go) → Muse Spark (OpenCode) →
DeepSeek V4 Flash (direct DeepSeek API). Chain order and models live in the
LiteLLM config only — clients never change. Editing the chain never touches
omp/Paseo config. (z.ai / GLM was removed from the chain 2026-09-10.)

Compliance — documentation standard: code duplication in rollback section

Lines 104–116 contain inline shell commands for manual rollback (ln -sf, systemctl restart, cp). Per docs/writing-documentation.md: "A doc that contains a multi-line code block … should move that code to a separate file and reference it." These concrete commands belong in swap.sh or a referenced example; the doc should only reference "see swap.sh rollback" or the manual-equivalent block in an examples/ file. Three commands inlined means three copies to maintain.

~/.paseo/litellm/swap.sh rollback # first choice, from any terminal

manual equivalent, if the script itself is broken:

ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm

undoing a PROMOTION (usable once): the chain the promote replaced

WARNING: works only once — a second promotion overwrites .prev

cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback

the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped

all the way back to Cloudflare (no LiteLLM):

~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Compliance — documentation standard: no domain glossary term for the "change cycle"

The "change cycle" (edit → test → activate → promote) is a domain-level procedure with a name, but it is spread across paragraphs (lines 54–72). It should be extracted as a named concept (e.g., change-cycle) with a single authoritative description, and other sections reference it by link. Currently the activate/promote/rollback sections each partially restate the cycle (lines 76–96), creating subtle duplication and making the cycle hard to locate.

## The change cycle — exact commands

```bash
cd ~/.paseo/litellm
swap.sh status                 # confirm: live side GREEN, blue free to edit
${EDITOR:-vi} config.blue.yaml  # order, models, keys, timeouts — blue ONLY
swap.sh test                   # gate: 3 probes must PASS (≈20 s, :4001 only)
swap.sh activate               # dry run: prints rollback + plan, switches nothing
swap.sh activate --yes         # re-runs the gate, then live -> blue, then verifies
# ... soak: use the system normally ...
swap.sh promote --yes          # gate, green.prev <- green, green <- blue, live -> green

Expected ends: activate prints served by: <model> naming the upstream that
answered; promote prints promoted: live side GREEN == BLUE.

activate and promote without --yes are dry runs: they print the plan and
the rollback/undo command, then exit 1. The acknowledgment step is mechanical,
not a matter of remembering.

What each part guarantees

  • test.sh — 3 probes on a throwaway instance at :4001, no live traffic:
    primary → 200, streamed primary ends with [DONE], and a copy of the
    config with the head deployment's api_base patched to an unreachable host
    still returns 200 from a fallback. The cascade probe takes the first
    api_base line in the file (that is the head deployment's) and patches it to
    http://127.0.0.1:9/, so it stays a real test when the head changes — it
    cannot pass vacuously.
  • swap.sh activate — refuses when BLUE is already live (the one edit
    blue-green exists to prevent) and refuses unless the gate passes. The flip
    is: symlink, restart, poll /health/readiness (≤120 s), then a real
    primary call. Any failure prints the rollback command.
  • swap.sh promote — refuses unless BLUE is live; runs the same gate; saves
    the outgoing chain to config.green.yaml.prev; copies blue → green and
    asserts cmp equality; then returns live to GREEN (byte-identical content,
    so zero behaviour change). This is what makes rollback content-neutral in
    steady state and frees blue for the next change.
  • swap.sh rollback — the emergency path, depending on NOTHING but the two
    config files and systemd: no provider, no env file, no gate. It flips to
    GREEN, restarts, and waits for health. Run it even if every LLM call is
    failing. rollback and the second half of promote are the same code path,
    so the emergency path is exercised on every promote.

</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-f23807a6b61011bbb185c47c8be435b9c277ac7e1233937fe301e133254d0c30R24-R35'><strong>Compliance — PRD: missing bot-review input data structure</strong></a>

The skill says "Host permissions make direct mutation impossible" and "verify the guard works", but gives no machine-checkable rule for the bot to enforce. The PRD requires (R2) that rules be "named, checkable, finding-framed" and (R3) that the bot sees all relevant standards. Without a defined finding (e.g., "a direct interactive deployment command in any CI script or runbook is a finding"), the rule relies on human review and is not enforced by the bot. Add a finding clause with a heuristic the bot can apply.
</summary>

```markdown
## Mechanical enforcement, not just documentation

A rule that only says "don't" gets broken under pressure. The durable
guard is permissions: on the production host, the deploy user's elevated
access is restricted to exactly the release scripts (a sudoers rule, or
equivalent); no interactive login can restart the app units or mutate the
deployment directly. Then a direct deployment is not merely discouraged —
it is impossible. Read-only diagnostics (logs, status) stay available.

Acceptance check: `sudo -l` as the deploy user lists ONLY the release
scripts; any other command is a finding. Run it when the host is built and
again after any permissions change.

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

The gate asked only whether the ALIAS answered, so a dead head hidden behind a working fallback validated clean while every request was served by the last resort. test.sh now asserts content (not just 200), streamed content deltas, and each chain member serving ALONE with fallbacks disabled; its member probe fails the config that was live today.

The skill states the rule and the mechanism: chain membership requires the member probe, OpenCode Go needs x-opencode-session plus its own user-agent (extra_headers), and the pin migration moved to skill://litellm-gateway/examples/ per the documentation standard instead of an inline script.
@github-actions

Copy link
Copy Markdown

Standalone PR Review

PR-Agent could not safely update the persistent review. This standalone result will not replace the canonical review.

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Compliance Issue — Violations of docs/PRD.md and docs/writing-documentation.md

The SKILL.md creates a new state abstraction (BLUE/GREEN, config.green.yaml.prev) that is not mentioned in the PRD (docs/PRD.md) as a domain concept. The PRD's 'Quality gate' requirement (JTBD 4) says the bot checks every PR against every standard, but also that the conventions repo passes its own checks (R10). The file exceeds documentation-quality checklist (docs/writing-documentation.md) for always-loaded file size (189 lines, exceeding ~150-200 line target), and lacks a single, clearly stated audience (it mixes instructions for agents, operators debugging Paseo, and infrastructure maintainers). Additionally, the rising action from line 158-176 discusses per-session pin migration for Paseo — a distinct topic that belongs in a separate doc linked from here, not inlined.

---
name: litellm-gateway
description: LiteLLM proxy for Paseo/OMP — deterministic blue-green chain runbook (edit blue -> test -> activate -> promote), rollback-first, one local failover chain
---

# LiteLLM Gateway

One local endpoint (`http://localhost:4000/v1`) owns the model fallback chain.
Clients (omp, Paseo agents) ask for model `primary`; LiteLLM routes through the
chain and fails over per request. Replaces Cloudflare AI Gateway for the text
default; Cloudflare stays configured as the manual rollback.

## The chain (what `primary` means)

`primary` = DeepSeek V4 Flash (OpenCode Go) → DeepSeek (direct). Chain order and
models live in the LiteLLM config only — clients never change. Editing the chain
never touches omp/Paseo config.

A model may sit in the chain only while it passes the gate on its own (probe
3): `muse-spark-1.3-contributor` does not — OpenCode 500s it on
`/chat/completions`, and its `/responses` path streams no content. Re-add a
model when the member probe passes for it.

## Blue-green: the sides and the invariant

~/.paseo/litellm/
config.green.yaml last-known-good chain — the rollback target
config.blue.yaml the ONLY side you edit
config.green.yaml.prev chain replaced at the last promote (one step back)
config.current.yaml symlink -> green|blue ; what :4000 serves
swap.sh status | test | activate --yes | promote --yes | rollback
test.sh validates a config on :4001 (4 probes, no live traffic)
env keys: OPENCODE_API_KEY, DEEPSEEK_API_KEY (600; never print)
(+ ZAI_API_KEY while config.green.yaml.prev still needs it)


INVARIANT: the live side is never the side you edit. Steady state is
`live = GREEN` with `blue == green`; blue is then free staging.

Service: `litellm.service` (systemd --user); `ExecStart` serves
`config.current.yaml`, `EnvironmentFile=~/.paseo/litellm/env`.

## Read the state, then follow the matching row

`~/.paseo/litellm/swap.sh status` prints the live side, whether the sides
differ, health, and the next command. This table is the whole decision
procedure — no judgement calls:

| live side | sides | meaning | next |
|---|---|---|---|
| GREEN | identical | steady state | edit `config.blue.yaml` |
| GREEN | differ | a change is staged, not live | `swap.sh test` → `swap.sh activate --yes` |
| BLUE | differ | new chain live, green = rollback target | soak → `swap.sh promote --yes` (or `swap.sh rollback` to revert) |
| BLUE | identical | green == blue, so live can return to green for free | `swap.sh promote --yes` |
| any | any | `health: NOT HEALTHY` | `swap.sh rollback` |

## The change cycle — exact commands

```bash
cd ~/.paseo/litellm
swap.sh status                 # confirm: live side GREEN, blue free to edit
${EDITOR:-vi} config.blue.yaml  # order, models, keys, timeouts — blue ONLY
swap.sh test                   # gate: 4 probes must PASS (:4001 only)
swap.sh activate               # dry run: prints rollback + plan, switches nothing
swap.sh activate --yes         # re-runs the gate, then live -> blue, then verifies
# ... soak: use the system normally ...
swap.sh promote --yes          # gate, green.prev <- green, green <- blue, live -> green

Expected ends: activate prints served by: <model> naming the upstream that
answered; promote prints promoted: live side GREEN == BLUE.

activate and promote without --yes are dry runs: they print the plan and
the rollback/undo command, then exit 1. The acknowledgment step is mechanical,
not a matter of remembering.

What each part guarantees

  • test.sh — 4 probes on throwaway instances at :4001, no live traffic:
    1. the chain alias returns 200 with content;
    2. the streamed alias delivers content deltas;
    3. every chain member answers alone, with fallbacks disabled — the probe
      that catches a broken head hidden behind a working fallback;
    4. a copy with the head's api_base patched to http://127.0.0.1:9/ still
      serves a fallback (the head's api_base is the first one in the file).
      Probes 1-3 assert content, not just HTTP 200: a reasoning model handed too
      small a token budget answers 200 with an empty message and looks healthy.
  • swap.sh activate — refuses when BLUE is already live (the one edit
    blue-green exists to prevent) and refuses unless the gate passes. The flip
    is: symlink, restart, poll /health/readiness (≤120 s), then a real
    primary call. Any failure prints the rollback command.
  • swap.sh promote — refuses unless BLUE is live; runs the same gate; saves
    the outgoing chain to config.green.yaml.prev; copies blue → green and
    asserts cmp equality; then returns live to GREEN (byte-identical content,
    so zero behaviour change). This is what makes rollback content-neutral in
    steady state and frees blue for the next change.
  • swap.sh rollback — the emergency path, depending on NOTHING but the two
    config files and systemd: no provider, no env file, no gate. It flips to
    GREEN, restarts, and waits for health. Run it even if every LLM call is
    failing. rollback and the second half of promote are the same code path,
    so the emergency path is exercised on every promote.

ROLLBACK FIRST — non-negotiable

Before any command that changes the live side, print the rollback command and
make the user acknowledge it. swap.sh prints it itself: on the dry run, on
activate, and on every failure.

~/.paseo/litellm/swap.sh rollback                # first choice, from any terminal
# manual equivalent, if the script itself is broken:
ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm
# undoing a PROMOTION (usable once): the chain the promote replaced
# WARNING: works only once — a second promotion overwrites .prev
cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback
# the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped
# all the way back to Cloudflare (no LiteLLM):
#   ~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Traps

  • NEVER accept "the alias answers" as proof the chain works: a dead head behind
    a working fallback answers every request while the chain silently runs on the
    last resort. Probe 3 is the guard; extra_headers satisfies OpenCode Go's
    session requirement.
  • NEVER edit the live side — a restart mid-edit serves the half-staged config.
    The guard is the check, not a lock: config.current.yaml is a symlink and
    the filesystem does not stop you writing to the live side's config file.
    Always run swap.sh status first and edit only the side it does not name as
    live. activate refuses to make the side you are editing the live one.
  • NEVER promote to make a failure go away: promotion runs the same gate, but it
    is still the step that copies the live chain over the rollback target. If what
    you are working around is something the gate cannot see, green is overwritten
    with the bad chain — rollback no longer reverts it, and the ways back are the
    one-step .prev restore (until the next promote) or the manual Cloudflare
    switch in ~/.omp/agent/config.yml.
  • A restart takes tens of seconds; swap.sh polls health for up to 120 s. A
    shell that times out while waiting is not evidence of failure — check
    swap.sh status.
  • test.sh needs the keys in the environment: run it as swap.sh test (which
    sources env), never by hand from a bare shell.

Clients

  • omp/Paseo agents: litellm/primary in modelRoles; litellm provider
    (baseUrl http://localhost:4000/v1) in ~/.omp/agent/models.yml.
    Restart the omp session to pick up provider/model changes.
  • designer/vision roles: still cloudflare-gateway/dynamic/image
    (Cloudflare image route, free of the text-chain).
  • PR-Agent (GitHub Actions): stays on Cloudflare — it cannot reach
    localhost.

Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in ~/.paseo/agents/<worktree>/<agent>.json
(config.model, duplicated in runtimeInfo.model and
persistence.metadata.model). Those pins OVERRIDE modelRoles.default:
sessions created before the migration keep
cloudflare-gateway/dynamic/fallback2 no matter how often the daemon
restarts. Migration = rewrite the pins:

  1. systemctl --user stop paseo FIRST — editing under a live daemon loses the
    race: in-memory session state flushes back on shutdown and reverts the edit.

  2. Rewrite the pins with skill://litellm-gateway/examples/migrate-paseo-pins.sh.
    Its match is exact, so lastError strings — which name the old model as
    model=... — are spared.

  3. systemctl --user start paseo, then re-grep: expect 0 hits.

New sessions follow agents.providers.omp.models[].isDefault in
~/.paseo/config.json; the per-session pin is what the Paseo UI model
dropdown sets.

Diagnostics

  • OpenCode Go requires x-opencode-session plus its own user-agent on every
    request; without them /chat/completions answers 400 MissingSessionID
    (deepseek-v4-flash) or 500 (muse) and the chain silently falls through.
    Both are set per deployment in extra_headers — never remove them.
  • OpenCode errors: DataPolicyError = the workspace has not opted in to its
    data policy; MonthlyLimitError = the OpenCode spend cap is reached. Both
    are fixed on the OpenCode side, not in this chain's config.
  • Provider configuration for these routes (Cloudflare, OpenCode, DeepSeek) is
    in skill://cloudflare-ai-gateway.
  • Keys live in ~/.paseo/litellm/env (600); never print them.

</details>

<details><summary><a href='https://github.com/ashbywinch/omp-config/pull/61/files#diff-038a320170778a71d630f86e75e89d1c293ab409207dcfdc9bf92b56d3bcb158R93-R97'><strong>Latent Concept – Undocumented Domain Name</strong></a>

The skill defines a 'promotion' step (lines 93-97) that represents a domain-level action (converging blue-green state after rollover). This is a latent domain concept — it has a name only as a shell subcommand (`swap.sh promote`) but no named entity, state machine, or glossary entry in the repo's doc set. According to docs/PRD.md (requirement R2), domain-level names must appear in the object model/TECHSPEC. The PR proposes this skill file, but introduces a behavioural concept (state convergence of a dual-config system) without a corresponding identifier in the required docs. Propose a new term (e.g. 'Canonicalization' or 'StateConvergence') and either put it in TECHSPEC or a new doc; otherwise it will be buried in shell aliases.
</summary>

```markdown
- `swap.sh promote` — refuses unless BLUE is live; runs the same gate; saves
  the outgoing chain to `config.green.yaml.prev`; copies blue → green and
  asserts `cmp` equality; then returns live to GREEN (byte-identical content,
  so zero behaviour change). This is what makes `rollback` content-neutral in
  steady state and frees blue for the next change.
Violation of docs/writing-documentation.md — Inline Config Snippet

Line 121 references a manual override to Cloudflare (~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2). This is a config-snippet equivalent to a YAML snippet a tool would parse, which the writing standard says must live in examples/, not in the doc body (see section 'Config snippets are code, not prose'). The snippet exists only in the doc; it should be extracted to examples/rollback-to-cloudflare.yml and referenced by link.

#   ~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2
Violation of docs/writing-documentation.md — Inline Rollback Commands

Lines 110-122 inline a multi-line script block showing rollback commands (a runnable script example). The writing standard requires that such multi-line code blocks (full config files, runnable scripts) move to a separate file (e.g. examples/rollback-manual.sh) and be referenced by link. Here, they are duplicated within the doc body. As these commands are critical for emergency procedures, they need to be tooling-checked and not rot as inline copies.

~/.paseo/litellm/swap.sh rollback # first choice, from any terminal

manual equivalent, if the script itself is broken:

ln -sf ~/.paseo/litellm/config.green.yaml ~/.paseo/litellm/config.current.yaml
systemctl --user restart litellm

undoing a PROMOTION (usable once): the chain the promote replaced

WARNING: works only once — a second promotion overwrites .prev

cp ~/.paseo/litellm/config.green.yaml.prev ~/.paseo/litellm/config.green.yaml
~/.paseo/litellm/swap.sh rollback

the prev chain needs its own key in env — keep ZAI_API_KEY until prev is dropped

all the way back to Cloudflare (no LiteLLM):

~/.omp/agent/config.yml -> modelRoles.default: cloudflare-gateway/dynamic/fallback2

Violation of docs/writing-documentation.md — Duplication of Code's Job

Lines 79-95 describe in prose what test.sh, swap.sh activate, swap.sh promote, and swap.sh rollback guarantee, effectively duplicating the script's own interface and behaviour. The documentation standard's 'Single source of truth — never duplicate' principle says a doc that restates a function signature, default value, or class structure is waste, and the code is the source of truth. These prose descriptions are identical to what the script's help/usage would say. Link to the script's source or let the script document itself.

- `test.sh` — 4 probes on throwaway instances at :4001, no live traffic:
  1. the chain alias returns 200 **with content**;
  2. the streamed alias delivers content deltas;
  3. **every chain member answers alone, with fallbacks disabled** — the probe
     that catches a broken head hidden behind a working fallback;
  4. a copy with the head's `api_base` patched to `http://127.0.0.1:9/` still
     serves a fallback (the head's `api_base` is the first one in the file).
  Probes 1-3 assert content, not just HTTP 200: a reasoning model handed too
  small a token budget answers 200 with an empty message and looks healthy.
- `swap.sh activate` — refuses when BLUE is already live (the one edit
  blue-green exists to prevent) and refuses unless the gate passes. The flip
  is: symlink, restart, poll `/health/readiness` (≤120 s), then a real
  `primary` call. Any failure prints the rollback command.
- `swap.sh promote` — refuses unless BLUE is live; runs the same gate; saves
  the outgoing chain to `config.green.yaml.prev`; copies blue → green and
  asserts `cmp` equality; then returns live to GREEN (byte-identical content,
Violation of docs/writing-documentation.md — Two Audiences, One Topic

The SKILL.md combines operational runbook (how to edit blue-green config, rollback) with diagnostics/troubleshooting (OpenCode errors, provider configuration, Paseo model migration). These serve different audiences: the first is for the operator deploying chain changes, the second for developers debugging Paseo agent behaviour. The diagnostic section (lines 178-189), especially the Paseo model pin migration (lines 157-176), should be in separate files (e.g. diagnostics.md and docs/migrate-paseo-pins.md) linked from the skill, not inlined.

## Clients

- omp/Paseo agents: `litellm/primary` in `modelRoles`; `litellm` provider
  (`baseUrl http://localhost:4000/v1`) in `~/.omp/agent/models.yml`.
  Restart the omp session to pick up provider/model changes.
- `designer`/`vision` roles: still `cloudflare-gateway/dynamic/image`
  (Cloudflare image route, free of the text-chain).
- PR-Agent (GitHub Actions): stays on Cloudflare — it cannot reach
  localhost.

### Paseo sessions pin the model per session — restarts never migrate them

Each session stores its model in `~/.paseo/agents/<worktree>/<agent>.json`
(`config.model`, duplicated in `runtimeInfo.model` and
`persistence.metadata.model`). Those pins OVERRIDE `modelRoles.default`:
sessions created before the migration keep
`cloudflare-gateway/dynamic/fallback2` no matter how often the daemon
restarts. Migration = rewrite the pins:

1. `systemctl --user stop paseo` FIRST — editing under a live daemon loses the
   race: in-memory session state flushes back on shutdown and reverts the edit.
2. Rewrite the pins with `skill://litellm-gateway/examples/migrate-paseo-pins.sh`.
   Its match is exact, so `lastError` strings — which name the old model as
   `model=...` — are spared.

3. `systemctl --user start paseo`, then re-grep: expect 0 hits.

New sessions follow `agents.providers.omp.models[].isDefault` in
`~/.paseo/config.json`; the per-session pin is what the Paseo UI model
dropdown sets.

## Diagnostics

- OpenCode Go requires `x-opencode-session` plus its own `user-agent` on every
  request; without them `/chat/completions` answers 400 `MissingSessionID`
  (`deepseek-v4-flash`) or 500 (`muse`) and the chain silently falls through.
  Both are set per deployment in `extra_headers` — never remove them.
- OpenCode errors: `DataPolicyError` = the workspace has not opted in to its
  data policy; `MonthlyLimitError` = the OpenCode spend cap is reached. Both
  are fixed on the OpenCode side, not in this chain's config.
- Provider configuration for these routes (Cloudflare, OpenCode, DeepSeek) is
  in `skill://cloudflare-ai-gateway`.
- Keys live in `~/.paseo/litellm/env` (600); never print them.
Possible Bug — While Loop Shell Pitfall

Line 24 uses printf '%s\n' "$files" | while IFS= read -r f; do ... done. When piping into a while loop in bash, each iteration of the loop runs in a subshell, so any variables set inside the loop are lost after the pipe. The script does not use any loop-modified variables after the pipe, so this is not a bug in the current version, but it is a latent pattern that will break if anyone later adds a counter or accumulator inside the loop. Prefer a while ... done <<< "$files" (here-string) or process substitution to avoid the subshell.

printf '%s\n' "$files" | while IFS= read -r f; do
  sed -i "s|\"model\": \"$OLD\"|\"model\": \"$NEW\"|g" "$f"
  printf 'rewritten %s\n' "$f"
done
Compliance — No Violations of docs/PRD.md

The script has no violations of docs/PRD.md. It is a migration utility script that implements the Paseo pins migration described in the SKILL.md. No PRD requirement is violated: it does not introduce new domain-level names outside the skill, does not change the quality gate or JTBD, and does not bypass any defined process. The scaffold's self-check (R10) compliance is not relevant because this is a one-time example script.

#!/usr/bin/env bash
# Rewrite the Paseo per-session model pins to `litellm/primary`.
#
# Run with the Paseo daemon STOPPED — in-memory session state flushes back on
# shutdown and reverts an edit made under a live daemon:
#
#   systemctl --user stop paseo
#   bash ~/.omp/agent/skills/litellm-gateway/examples/migrate-paseo-pins.sh
#   systemctl --user start paseo
#
# The match is exact, so historical `lastError` strings — which name the old
# model as `model=...` — are spared.
set -euo pipefail

OLD=cloudflare-gateway/dynamic/fallback2
NEW=litellm/primary
AGENTS=${AGENTS_DIR:-$HOME/.paseo/agents}

if ! files=$(grep -rl "\"model\": \"$OLD\"" "$AGENTS" --include='*.json'); then
  printf 'nothing to do: no file under %s carries the old pin\n' "$AGENTS"
  exit 0
fi

printf '%s\n' "$files" | while IFS= read -r f; do
  sed -i "s|\"model\": \"$OLD\"|\"model\": \"$NEW\"|g" "$f"
  printf 'rewritten %s\n' "$f"
done

if remaining=$(grep -rl "\"model\": \"$OLD\"" "$AGENTS" --include='*.json'); then
  printf 'FAIL: %s file(s) still carry the old pin\n' "$(printf '%s\n' "$remaining" | wc -l)"
  exit 1
fi
printf 'OK: every pin now reads %s\n' "$NEW"
Compliance — Requires `make install` Restart Notice

According to the user's instructions, a change to a skill must note that the change needs make install + omp restart to take effect. The SKILL.md does not document this requirement anywhere. A deployment skill change must include this note. Add a line like: 'After this file is added, run make install from the repo root and restart the omp session for the skill to be visible to agents.'

---
name: prod-deploy-via-release-only
description: Production changes go ONLY through the project's release process; ad-hoc commands on a host are never a deployment path. If the process is too slow, improve it — never bypass it.
---

# Production deploy via the release process only

A production change — code, schema, config, restart — is deployed through
the project's **defined release process** (its CI/release workflow, or the
release scripts that workflow invokes). Ad-hoc shell commands on a
production host (pull + restart, direct config edits, manual restarts) are
**not** a deployment path, however urgent the fix feels. If the release
process is too slow or awkward to use, the answer is to improve the
process, never to bypass it.

## Why

The release process exists to make production changes reviewable, tested
(standby + smoke before switch), and reversible (rollback). Every ad-hoc
bypass erodes that: an unreviewed change ships, the smoke gate is skipped,
rollback is impossible, and the next person (or agent) follows the
precedent.

## Mechanical enforcement, not just documentation

A rule that only says "don't" gets broken under pressure. The durable
guard is permissions: on the production host, the deploy user's elevated
access is restricted to exactly the release scripts (a sudoers rule, or
equivalent); no interactive login can restart the app units or mutate the
deployment directly. Then a direct deployment is not merely discouraged —
it is impossible. Read-only diagnostics (logs, status) stay available.

Acceptance check: `sudo -l` as the deploy user lists ONLY the release
scripts; any other command is a finding. Run it when the host is built and
again after any permissions change.

## Guardrails

- Deploy via the workflow/release script, or don't deploy.
- A broken release process is a blocker to fix; skipping it is never the fix.
- Host permissions make direct mutation impossible; verify the guard works
  (attempt the blocked operation and confirm it fails).
- One-time infrastructure hardening (permissions, keys) that makes the
  guard real is legitimate — do it deliberately, verify it, and record it
  in the reproducible setup so fresh hosts get the guard automatically.
Compliance — No Violations of docs/PRD.md

The SKILL.md is consistent with the PRD's JTBD 3 (knowledge loop: lessons codified into conventions) and JTBD 4 (quality gate: ad-hoc deploys bypass review). It does not violate any PRD requirement. It could be enhanced with a concrete checkable acceptance test or gate (as required by docs/writing-documentation.md: 'Standards pair with their enforcement'), but the mechanical enforcement paragraph (lines 24-35) describes a permissions-based guard which is acceptable.

---
name: prod-deploy-via-release-only
description: Production changes go ONLY through the project's release process; ad-hoc commands on a host are never a deployment path. If the process is too slow, improve it — never bypass it.
---

# Production deploy via the release process only

A production change — code, schema, config, restart — is deployed through
the project's **defined release process** (its CI/release workflow, or the
release scripts that workflow invokes). Ad-hoc shell commands on a
production host (pull + restart, direct config edits, manual restarts) are
**not** a deployment path, however urgent the fix feels. If the release
process is too slow or awkward to use, the answer is to improve the
process, never to bypass it.

## Why

The release process exists to make production changes reviewable, tested
(standby + smoke before switch), and reversible (rollback). Every ad-hoc
bypass erodes that: an unreviewed change ships, the smoke gate is skipped,
rollback is impossible, and the next person (or agent) follows the
precedent.

## Mechanical enforcement, not just documentation

A rule that only says "don't" gets broken under pressure. The durable
guard is permissions: on the production host, the deploy user's elevated
access is restricted to exactly the release scripts (a sudoers rule, or
equivalent); no interactive login can restart the app units or mutate the
deployment directly. Then a direct deployment is not merely discouraged —
it is impossible. Read-only diagnostics (logs, status) stay available.

Acceptance check: `sudo -l` as the deploy user lists ONLY the release
scripts; any other command is a finding. Run it when the host is built and
again after any permissions change.

## Guardrails

- Deploy via the workflow/release script, or don't deploy.
- A broken release process is a blocker to fix; skipping it is never the fix.
- Host permissions make direct mutation impossible; verify the guard works
  (attempt the blocked operation and confirm it fails).
- One-time infrastructure hardening (permissions, keys) that makes the
  guard real is legitimate — do it deliberately, verify it, and record it
  in the reproducible setup so fresh hosts get the guard automatically.

…ent-shaped traffic

Muse Spark is unreachable on OpenCode Go's /chat/completions route (500 at the provider edge) but serves normally via /responses, which LiteLLM bridges to chat completions including streaming — so the deployment is openai/responses/muse-spark-1.3-contributor.

Probe traffic is agent-shaped on a reasoning-sized budget: a trivial reply-OK prompt makes a reasoning model spend its budget thinking and answer 200 with empty content, which the gate would misread as a broken model.
…ting them

The skill duplicated config and code contents — the chain order and model ids, the subcommand list, the probe inventory, the command syntax. Those live in config.blue.yaml, swap.sh and test.sh; the skill now says where to read them and keeps only what is not in any file: the invariant that the live side is never edited, the gate rule, rollback-first, the traps, and the client wiring.
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 54e9fcb

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 43ce1fe

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 2a6949a

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit b3a0ecb

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit b3a0ecb

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

@omp-harness

omp-harness Bot commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Addressing the ACTIVE finding on b3a0ecb:

skills/litellm-gateway/SKILL.md references scripts not included in this repo — declined deliberately. The three scripts (swap.sh, test.sh, snapshot-live.sh) are versioned in ashbywinch/bluegreen-config and deployed into the state directory by that repo's make install (copies, not symlinks, so the rollback path survives a broken checkout); make check-installed fails when the installed copy drifts. The skill names the commands and what to do with their output rather than restating them, which is the distribution mechanism skills/update-skills/SKILL.md prescribes for companion tools. Vendoring a second copy here would recreate exactly the drift this PR removes, and gateway tooling is not omp-config content.

For the earlier compliance findings: the description now states the make test result (61 docs, 42 skills, links resolve, 18 tests) and that make install plus an omp restart are needed for the skills to take effect.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 9cad28a

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 228d274

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

@omp-harness
omp-harness Bot merged commit 6ef75dd into main Oct 31, 2026
3 checks passed
@omp-harness
omp-harness Bot deleted the skills/litellm-gateway-and-prod-deploy branch October 31, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant