Files
Linux_post_install/AgentsReport/architect/2026-09-06_ai-cost-window-design.md
T
Your Name 01aa7f3e8f
gates / consistency-and-conventions (push) Successful in 32s
fix: OpenRouter 402 — send max_tokens cost cap; make session window configurable
User hit 'API error 402: ... You requested up to 131072 tokens, but can
only afford 4511' on the assist alias: no provider ever sent max_tokens,
so OpenRouter's credit pre-check billed the routed model's full
worst-case output; user also asked to bound session history to the last
5 requests/responses.

Architect decisions:
- AI_MAX_TOKENS (num, default 2048): sent as max_tokens on OpenRouter
  and generationConfig.maxOutputTokens on Gemini — a real per-request
  cost ceiling. llamacpp unchanged (local/free, no pre-check).
- AI_SESSION_TURNS (num, default 40 kept back-compat; messages, 2 per
  exchange — 10 = last 5 conversations): resolved lazily in session_push
  because config loads after the hardcoded line-25 default.
- Both registered in the bin/pos-ai POS_CONFIG @General section, so they
  appear in 'pos config ai' with num: validation.

Reviewer hardening (CHANGES_REQUIRED -> fixed): unguarded env input could
reach jq tonumber (0/-5/010/abc all savable via config-ui's ^-?[0-9]+$)
and abort the CLI; both providers and session_push now guard with
^[1-9][0-9]*$ and fall back to the default.

Verified: fake-curl shim smoke (16 provider-body + 12 session-window
checks incl. the 010-regression proof), make gen idempotent, make check
OK, make lint 0 FAIL/0 WARN, make test 17 files / 299 checks / 0 fail
(~49s), bash -n clean, git diff --check clean. Reviewer ACCEPT (twice).

Tester regression round (permanent provider-body + session-pruning
coverage) intentionally not run this cycle — user's call; remains a
documented follow-up.
2026-09-07 07:25:38 -04:00

308 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI cost & session-window design — OpenRouter 402 + bounded session memory
**Date:** 2026-09-06
**Author:** Architect (big-pickle)
**HEAD:** 8ce5479 (clean tree)
**Mode:** design-only (no file edits besides this report)
## TL;DR
Two tightly associated defects in `pos ai` / `bin/pos-ai`:
1. **402 root cause** — no provider ever sends `max_tokens`, so OpenRouter's credit
pre-check charges the routed model's full worst-case output (131072 on
`openrouter/auto`) and rejects balances under that. Fix: send an explicit capped
`max_tokens`.
2. **Unbounded-ish session**`MAX_SESSION_TURNS=40` counts *messages* (20
exchanges); user wants "last 5 req/response" = 10 messages.
**Decisions:** add `AI_MAX_TOKENS` (num, default **2048**) honored by openrouter
(body `max_tokens`) and gemini (`generationConfig.maxOutputTokens`); **skip** llamacpp.
Add `AI_SESSION_TURNS` (num, default **40** — backward compatible) honored lazily in
`session_push`; user sets **10** for 5 pairs. Both vars declared in the `@General`
section of the `# POS_CONFIG:` header, docs updated. No chat/alias wrapper changes.
**Open items:** none blocking. Tester should add provider-body + session-pruning
coverage (see §Testing).
---
## Verified fact confirmation (AD)
All user-reported facts confirmed against source at HEAD:
**A — 402 root cause: CONFIRMED.**
- `lib/ai-providers/openrouter.sh:22-23` — body is only `{model,messages}`; no
`max_tokens`. `:24-29` POSTs straight to OpenRouter with unchanged body.
- `lib/ai-providers/gemini.sh:17-19` — body only `{contents,...}`; no
`generationConfig`. (`:20-23` adds only `systemInstruction`.)
- `lib/ai-providers/llamacpp.sh:32-33` — body `{model,messages,stream:false}`.
- Conclusion: none carry a generation cap → OpenRouter 402 with the user's thin
balance. Fix is to send an explicit `max_tokens`.
**B — session window: CONFIRMED.**
- `bin/pos-ai:25` `MAX_SESSION_TURNS=40`.
- `session_push()` `bin/pos-ai:285-290``'.messages |= .[-"$MAX_SESSION_TURNS":]'`
prunes to last N **messages** (40 msgs = 20 exchanges). User wants 5 pairs = 10 messages.
**C — config surface: CONFIRMED.**
- `# POS_CONFIG:` header `bin/pos-ai:6`, scope `ai | ai.env`.
- `num:` type already used in the same header (`LLAMACPP_CTX_SIZE=num:…`,
`LLAMACPP_GPU_LAYERS=num:…`).
- Validation: `lib/config-ui.sh:419` `*,num,*) [[ "$val" =~ ^-?[0-9]+$ ]]` — integer-only
on *entry*; empty input ="kept current value" (`:468-471`); `-` = clear (`:472-477`).
So `num:` + empty/unset → falls back to code default. Clean.
- Env precedence: `load_env_file` (`lib/config-ui.sh:336-357`) exports a file key only
when the variable is not already set in the environment (`:351-353`) → env beats
file beats default. Providers read config via env (`AI_API_KEY` pattern).
- `PROVIDER_CONFIG` headers exist on `openrouter.sh:7-8`, `gemini.sh:7-8`,
`llamacpp.sh:7` for *provider-specific* keys.
**D — provider resolution: CONFIRMED.**
- `bin/pos-ai:679-683``--provider` flag > `AI_PROVIDER` env > `gemini`.
- `load_config()` `bin/pos-ai:141-151` loads `ai.env` (+ legacy files); called by
`resolve_key` (line 155) and at provider resolution (line 681).
- Call site `bin/pos-ai:522` (ask) and `:557` (chat): `provider_generate "$model" "$messages" "$system"`.
**Gates/tests scan:**
- No test pins `MAX_SESSION_TURNS`, the provider request bodies, or header text
(`t-config-precedence.sh` targets `pos-ai-server`, a separate tool). No forced
test update.
- `DOC/POS.md` has a hand-maintained ai.env config table (`:88-97`) and mentions
"capped at 40 turns" at `:64`. `DOC/HOWTO.md:45` and `DOC/AGENT_Context_Project.md:491`
list ai.env vars (hand-maintained). All need doc rows/bumps for the new vars.
---
## Decision 1: `AI_MAX_TOKENS` — cap generation tokens
**Status: [DECIDED]**
### Options & trade-offs
**Option 1 (chosen) — single global `AI_MAX_TOKENS=num`, default 2048, honored by remote providers (openrouter + gemini); skip llamacpp.**
- *Advantages:* smallest change that fixes the 402 (OpenRouter pre-check sees a
capped cost) and is a real per-request cost ceiling; one var, one default; fits
the existing `AI_*` env naming and the tool-level `@General` config section; no
new per-provider surface.
- *Costs:* remote providers share one ceiling (no per-provider cap without user
intervention).
- *Risks:* a too-low cap truncates long answers — mitigated by default 2048 being
ample for terse `ask`/`chat` CLI answers; user can raise it.
- *Reasoning for default 2048:* conservative (user's balance affords ~4511 tokens at
routed price, so 2048 passes the pre-check with margin) while being a practical,
real ceiling. 2048 tokens ≈ several thousand chars — plenty for the terse,
commands-first assistant role this tool plays.
**Option 2 — per-provider caps via `PROVIDER_CONFIG` (e.g. `AI_MAX_TOKENS` on openrouter.sh, gemini.sh).**
- *Advantages:* independent ceilings per provider.
- *Costs:* two declarations, redundant section plumbing, and the *default* (which is
the entire point) still has no shared home → awkward. `PROVIDER_CONFIG` is for
provider-specific concerns; a cost ceiling + 402 pre-check over both remote
providers is tool-level, not provider-specific.
**Option 3 — no gemini cap; only openrouter.**
- *Advantages:* minimal (402 only affects OpenRouter).
- *Costs:* leaves Gemini without any cost ceiling while introducing the same var —
inconsistent, and Gemini's own pricing can surprise. Rejected.
**Option 4 — include llamacpp too (`max_tokens` in body; it accepts it).**
- *Advantages:* provider parity on the OpenAI-compatible endpoint.
- *Costs:* local & free — no credit pre-check, no cost. Adds surface with zero user
benefit. Rejected on the "smallest sufficient design" principle.
### Implementation contract
- Env var name: **`AI_MAX_TOKENS`**, type `num`, default **2048**.
- OpenRouter body (`openrouter.sh:22-23`): add `max_tokens`.
- Gemini body (`gemini.sh:17-19`): add `generationConfig.maxOutputTokens`.
- llamacpp: **no change**.
- Providers read `"${AI_MAX_TOKENS:-2048}"` from env; value is present because
`load_config` runs before `provider_generate` (resolve path confirmed in D).
- Declare in `@General` section of `# POS_CONFIG:` header (`bin/pos-ai:6`).
---
## Decision 2: `AI_SESSION_TURNS` — bounded session window
**Status: [DECIDED]**
### Options & trade-offs
**Option 1 (chosen) — new `AI_SESSION_TURNS=num`, default 40 (unchanged), resolved lazily in `session_push`.**
- *Advantages:* fully backward compatible — no silent memory truncation for existing
users. The user's "last 5 req/response" = setting `AI_SESSION_TURNS=10`. One var,
one default.
- *Costs:* existing users must opt in (they already have the 40 behavior, so no
regression).
- *Why keep default 40:* backward compatibility is a hard project value; silently
shifting the default changes session context for *every* user, discards history
they may rely on, and is a behavioral change not requested globally (only for this
user). Keep 40.
**Option 2 — change the default to 10.**
- *Advantages:* meets the stated want out-of-the-box.
- *Costs:* silent behavior change for all users; discards memory; not requested
globally. Rejected — keep the change opt-in via the new var.
### Semantics (must be documented)
The existing prune is `.messages |= .[-N:]` where N counts **messages** — 2 messages
per exchange. So `AI_SESSION_TURNS=10` ⇒ last **5** exchanges (5 user + 5 assistant).
The config description must state: "message count (2 per exchange); 10 = last 5
exchanges".
### Implementation constraint — lazy resolution (important)
`MAX_SESSION_TURNS` is currently assigned at `bin/pos-ai:25`, which executes at
top-level **before** `load_config` is first called (line 681). If we wrote
`MAX_SESSION_TURNS="${AI_SESSION_TURNS:-40}"` at line 25, an `AI_SESSION_TURNS` set
*only in ai.env* would not yet be loaded → always 40.
Therefore:
- Keep **line 25** as-is (`MAX_SESSION_TURNS=40`), used for the help text (`:67`
shows the default, accurate).
- In `session_push()` (lines 285-290), resolve **lazily**:
`local n="${AI_SESSION_TURNS:-$MAX_SESSION_TURNS}"` and use `$n` in the jq prune.
Because `session_push` runs inside `cmd_ask`/`cmd_chat` — after `load_config`
(via `resolve_key` at `:499`/`:538`) has exported `AI_SESSION_TURNS` into the
process env — the ai.env value is honored. Shell-exported `AI_SESSION_TURNS`
wins too (env-wins in `load_env_file`).
- Help text `bin/pos-ai:67` stays accurate ("capped at $MAX_SESSION_TURNS turns")
since the default remains 40. Optionally add a `Config:` help line documenting the
var — recommended, and it confirms "turns = messages, 10 = 5 pairs".
---
## Decision 3: `# POS_CONFIG:` header change
**Status: [DECIDED]**
- Single `@General` section addition (not per-provider `PROVIDER_CONFIG`):
- `AI_MAX_TOKENS=num:Max output tokens per request (default 2048; OpenRouter/Gemini cost cap)`
- `AI_SESSION_TURNS=num:Session message cap — 2 per exchange (default 40 = 20 exchanges; 10 = last 5)`
- Place both in the `@General` section alongside `AI_SYSTEM_PROMPT` (end of the
long header line, `bin/pos-ai:6`).
- **Why @General, not PROVIDER_CONFIG:** the default is shared across providers
(2048, 40) and both caps are tool-level concerns. `PROVIDER_CONFIG` is reserved for
provider-specific keys (API keys, models). A single global declaration is the
cleanest and avoids duplicating the default in two provider files.
- `num:` type confirmed suitable: `lib/config-ui.sh:419` enforces integer on entry;
empty=keep current, `-`=clear (`:468-477`); unset → code default. No empty-parse
concern.
- Provider bodies consume the vars from env, so no `PROVIDER_CONFIG` additions are
needed on `openrouter.sh`/`gemini.sh`. (They could be added later if per-provider
caps are ever wanted — out of scope now.)
---
## Decision 4: Docs & gates
**Status: [DECIDED]**
- **`DOC/POS.md`** (hand-maintained):
- ai.env table (`:88-97`): add rows for `AI_MAX_TOKENS` (no/`2048`/"Max output
tokens per request (OpenRouter/Gemini cost cap)") and `AI_SESSION_TURNS`
(no/`40`/"Session message cap — 2 per exchange; 10 = last 5 exchanges"). The
`AI_SYSTEM_PROMPT` row (`:93`) is the placement anchor.
- Line 64 text "capped at 40 turns" remains true (default unchanged) — no edit
strictly needed, but a short parenthetical "(configurable via AI_SESSION_TURNS)"
is recommended.
- **`DOC/HOWTO.md:45`** — append `AI_MAX_TOKENS`, `AI_SESSION_TURNS` to the listed
ai.env vars.
- **`DOC/AGENT_Context_Project.md:491`** — append the two vars to the ai.env
summary parenthetical (hand-maintained).
- **`make gen`**: header text change does not add commands/subcommands/flags, so the
generated tree/dispatch tables are unaffected; `completions/pos.bash` config-scope
table regenerates to include the new keys. Run `make gen` (deterministic, `LC_ALL=C`
per convention), then `make check`, then `make lint`.
- **Line-count rows** above the filetable marker in `AGENT_Context_Project.md`: only
bump a row if a `pos-*`/`lib/*` file's length changes (it does — lib/ai-providers
grow; bin/pos-ai grows). Do not touch rows for files that don't change.
- **tests**: existing suite does not pin the session default or provider bodies, so
nothing is *forced*. Recommend new coverage (§Testing).
---
## Decision 5: Scope fence
**Status: [DECIDED]**
**Approved outcome:** OpenRouter 402 eliminated (explicit capped `max_tokens` on
remote providers) and session history bounded via configurable `AI_SESSION_TURNS`.
**In-scope files:**
- `bin/pos-ai``# POS_CONFIG:` header (line 6, @General additions); `session_push`
lazy `AI_SESSION_TURNS` resolution (lines 285-290); optional `Config:` help lines
for the two vars. Line 25 stays `MAX_SESSION_TURNS=40`.
- `lib/ai-providers/openrouter.sh` — add `max_tokens` to body (lines 22-23).
- `lib/ai-providers/gemini.sh` — add `generationConfig.maxOutputTokens` (lines 17-19).
- `lib/ai-providers/llamacpp.sh`**no change**.
- Docs (hand-maintained): `DOC/POS.md`, `DOC/HOWTO.md`, `DOC/AGENT_Context_Project.md`.
- tests (later, Tester).
**Allowed interface changes:** two new config keys in scope `ai`; provider request
bodies gain a token cap. Provider call signature `provider_generate "$model" "$messages" "$system"`
is unchanged.
**Explicitly out of scope:** `bin/pos-ai-server`, chat/alias wrappers, `pos-ai-alias`,
HuggingFace downloader, other tools, per-provider caps, changing `MAX_SESSION_TURNS`
default, any llmacpp body change.
**Architectural constraints:**
- No new provider-side config plumbing; vars read from env (`AI_*` convention).
- Backward compatible: session default 40 and empty/unset values fall back to
documented defaults.
- Deterministic `make gen`; `make check` + `make lint` green (definition of done).
**Open risks:**
- `AI_MAX_TOKENS` too low truncates long `--full` answers — default 2048 mitigates;
user can raise.
- env-vs-file precedence: exported env var beats config file (per `load_env_file`) —
expected and documented.
**Verification:**
- `bash -n` clean; `make gen`/`make check`/`make lint` green.
- A 402 reproduction no longer triggers when `AI_MAX_TOKENS` is set/at default.
- `AI_SESSION_TURNS=10` prunes the session to last 5 exchanges.
---
## Testing budget (suggestion for Tester / Builder verification)
- **Provider body cap:** source each provider adapter in a sandbox with `curl`
stubbed, assert the request JSON contains `max_tokens` (openrouter) / a truthy
`generationConfig.maxOutputTokens` (gemini). Pattern: `t-config-precedence.sh` style
curl-log capture with a fake `curl` in `PATH` (see existing tests).
- **Session pruning:** drive `session_push` with a crafted messages JSON and
`AI_SESSION_TURNS=10`, assert exactly the last 10 messages remain (5 pairs); and
`AI_SESSION_TURNS` unset → 40 retained (default commits to backward compat).
- **Help/default fidelity:** `pos ai --help` still shows 40; `pos config ai` lists the
two new `num:` keys and rejects a non-integer (`cfg_validate`).
- **Regression:** existing `t-config-precedence.sh`, `run-tests` full suite green.
---
## Handoff
**Status:** DECISION_READY
**Problem:** OpenRouter 402 (no `max_tokens` → full worst-case pre-check) and
unbounded session memory; user wants 5 req/response.
**Decision:** add `AI_MAX_TOKENS=num` (default 2048) honored by openrouter + gemini
(skip llamacpp); add `AI_SESSION_TURNS=num` (default 40, backward compatible) resolved
lazily in `session_push`; both in `@General` config section; docs updated.
**Ownership:** `bin/pos-ai` + `lib/ai-providers/{openrouter,gemini}.sh` (+ docs).
**Interfaces:** two new `ai`-scope config keys; provider bodies gain a token cap.
Call signature unchanged.
**Approved scope / constraints / verification / out-of-scope:** see Decision 5.
**Risks:** see Decision 5 (token-cap truncation; env-vs-file precedence — all mitigated).
**Recommended next agent:** **Builder**
**Reason:** the architecture and scope are fully specified with exact line-level
changes (no architectural ambiguity left). Builder can implement without making
architecture decisions. Tester follows for the recommended coverage.