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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,11 @@ recent output at `CURATED_HOUR=7` in `TZ` by default, after Scout's 05:30 local
extends it only when `CURATED_DEVELOPING_STORIES=true`: validated story decisions may follow a
changed source version through the same delivery ledger. `/status` and sanitized job outcomes report allowances,
spacing, pending deliveries, last input age, and recent check/editorial outcomes.
- Developing-story mode keeps at most eight Scout sources in an 18,000-character request including
the serialized response schema, four confirmed prior deliveries, 100 current story pointers, and
500 immutable decisions for 90 days. Drafts, the one allowed revision, and independent reviews
- Developing-story mode keeps at most eight Scout sources, four confirmed prior deliveries,
100 current story pointers, and 500 immutable decisions for 90 days. The selected profile admits
complete requests using its existing 32,768-token conservative UTF-8/schema bound; legacy mode
retains its 18,000-character limit. Oversized requests fail before budget reservation or dispatch.
Drafts, the one allowed revision, and independent reviews
use provider-enforced strict JSON schemas plus local parsing, exact-quote validation, and semantic
review. A valid draft uses two or four model calls; an initial local-validation failure reuses the
one revision slot and uses three, so every path remains at four or fewer. Actual coverage is derived by
Expand Down
7 changes: 6 additions & 1 deletion roger/brains/curated.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from openai import OpenAIError

from roger.identity import ROGER_IDENTITY
from roger.llm import LLM, BudgetExceeded, CuratedModelError, LLMConfigError
from roger.llm import LLM, BudgetExceeded, CuratedModelError, CuratedRequestError, LLMConfigError
from roger.scout_source import collect_from_scout
from roger.store import CURATED_CHECK_LEASE_SECONDS, Store

Expand Down Expand Up @@ -557,6 +557,9 @@ async def finish(status: str, *, retry_at: float = 0) -> None:
except LLMConfigError:
await finish("configuration")
return await result("curated brain not configured")
except CuratedRequestError as exc:
await finish("rejected")
return await result(f"model request rejected: {exc}; skipped")
except CuratedModelError as exc:
await finish("rejected")
return await result(f"model response rejected: {exc.status}; skipped")
Expand Down Expand Up @@ -693,6 +696,8 @@ async def preview_curated_job(
return {"status": f"budget exceeded{reason}; skipped"}
except LLMConfigError:
return {"status": "curated brain not configured"}
except CuratedRequestError as exc:
return {"status": f"model request rejected: {exc}; skipped"}
except CuratedModelError as exc:
return {"status": f"model response rejected: {exc.status}; skipped"}
except OpenAIError:
Expand Down
39 changes: 28 additions & 11 deletions roger/brains/stories.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

import discord

from roger import curated_protocol
from roger.brains.curated import DraftError, _json_object, _response_text, _safe_link
from roger.identity import ROGER_IDENTITY
from roger.llm import LLM, curated_workflow
from roger.llm import LLM, CuratedRequestError, curated_workflow

MAX_SOURCES = 8
SOURCE_EXCERPT_CAP = 1_600
Expand Down Expand Up @@ -588,6 +589,7 @@ def relevant(item: dict[str, Any]) -> bool:
def _model_input(
sources: list[dict[str, Any]],
history: dict[str, list[dict[str, Any]]],
*, input_token_limit: int | None = None,
) -> dict[str, Any]:
story_ids = {source["story_id"] for source in sources}
stories = []
Expand All @@ -606,8 +608,7 @@ def _model_input(
"stories": stories,
"confirmed_sent": delivered,
}
encoded = json.dumps(data, ensure_ascii=False)
if len(encoded) > MAX_CONTEXT_CHARS:
if input_token_limit is None and len(json.dumps(data, ensure_ascii=False)) > MAX_CONTEXT_CHARS:
raise DraftError("developing story context exceeds limit")
return data

Expand Down Expand Up @@ -684,12 +685,17 @@ def _messages(
system: str,
payload: dict[str, Any],
response_format: dict[str, Any],
*, input_token_limit: int | None = None,
) -> list[dict[str, str]]:
content = _compact_json(payload)
request_chars = len(system) + len(content) + len(_compact_json(response_format))
if request_chars > MAX_CONTEXT_CHARS:
raise DraftError("developing story model context exceeds limit")
return [{"role": "system", "content": system}, {"role": "user", "content": content}]
messages = [{"role": "system", "content": system}, {"role": "user", "content": content}]
if input_token_limit is None:
request_chars = len(system) + len(content) + len(_compact_json(response_format))
if request_chars > MAX_CONTEXT_CHARS:
raise DraftError("developing story model context exceeds limit")
elif curated_protocol.input_token_upper_bound(messages, response_format) > input_token_limit:
raise CuratedRequestError("input context exceeds limit")
return messages


def _story_response_text(response: Any) -> str:
Expand Down Expand Up @@ -952,6 +958,7 @@ async def _review(
REVIEW,
review_payload(decision, sources, history),
REVIEW_RESPONSE_FORMAT,
input_token_limit=getattr(llm, "curated_input_token_limit", None),
),
curated_review=True,
response_format=REVIEW_RESPONSE_FORMAT,
Expand All @@ -977,10 +984,13 @@ async def decide(
)
if not sources:
raise DraftError("no usable developing story evidence")
model_input = _model_input(sources, history)
input_token_limit = getattr(llm, "curated_input_token_limit", None)
model_input = _model_input(sources, history, input_token_limit=input_token_limit)
response = await llm.complete(
"curated",
_messages(SYSTEM, model_input, DECISION_RESPONSE_FORMAT),
_messages(
SYSTEM, model_input, DECISION_RESPONSE_FORMAT, input_token_limit=input_token_limit,
),
curated_story=True,
response_format=DECISION_RESPONSE_FORMAT,
)
Expand All @@ -1005,7 +1015,10 @@ async def decide(
}
response = await llm.complete(
"curated",
_messages(revision_system, revision_payload, DECISION_RESPONSE_FORMAT),
_messages(
revision_system, revision_payload, DECISION_RESPONSE_FORMAT,
input_token_limit=input_token_limit,
),
curated_story=True,
response_format=DECISION_RESPONSE_FORMAT,
)
Expand Down Expand Up @@ -1053,7 +1066,11 @@ def input_fingerprint(
]
profile = getattr(settings, "curated_request_profile", "legacy")
if profile != "legacy":
payload.append({"request_profile": profile})
payload.append({
"request_profile": profile,
"input_budget_policy": "utf8-json-plus-2048-v1",
"input_token_limit": curated_protocol.MAX_INPUT_TOKENS,
})
return hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()
).hexdigest()
Expand Down
13 changes: 12 additions & 1 deletion roger/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ class LLMConfigError(RuntimeError):
"""A brain was invoked with no models configured (MODEL_<BRAIN> is empty)."""


class CuratedRequestError(RuntimeError):
"""A local input-admission failure, before reservation or provider dispatch."""


class CuratedModelError(RuntimeError):
"""A classified non-publishing result; never includes vendor exception text."""

Expand Down Expand Up @@ -138,6 +142,13 @@ def __init__(self, settings: Settings, store: Store) -> None:
# Opt-in OpenRouter `reasoning.effort` passthrough — only gigabrain ever sets this today.
self._reasoning_effort = {"gigabrain": settings.gigabrain_reasoning_effort or None}

@property
def curated_input_token_limit(self) -> int | None:
"""Selected profiles admit complete requests by their conservative token bound."""
if self._curated_profile == "legacy":
return None
return curated_protocol.MAX_INPUT_TOKENS

async def complete(
self,
brain: str,
Expand Down Expand Up @@ -239,7 +250,7 @@ async def _complete_curated_profile(
raise LLMConfigError("unsupported Curated profile request") from None
input_bound = curated_protocol.input_token_upper_bound(messages, response_format)
if input_bound > curated_protocol.MAX_INPUT_TOKENS:
raise LLMConfigError("Curated request exceeds the input reservation maximum")
raise CuratedRequestError("input context exceeds limit")
reservation = {
"workflow_id": workflow_id, "role": "reviewer" if review else "writer",
"profile": profile.name, "input_tokens": input_bound,
Expand Down
60 changes: 59 additions & 1 deletion tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
MAX_ATTEMPTS,
BudgetExceeded,
CuratedModelError,
CuratedRequestError,
LLMConfigError,
_retry_after_seconds,
curated_workflow,
Expand Down Expand Up @@ -856,14 +857,71 @@ async def test_profile_counts_utf8_and_schema_before_reserving(monkeypatch, tmp_
store = await Store(str(tmp_path / "input.db")).open()
llm = LLM(Settings(), store)
try:
with pytest.raises(LLMConfigError, match="input reservation maximum"):
with pytest.raises(CuratedRequestError, match="input context exceeds limit"):
await _profile_call(llm, content="é" * 15_000)
assert (await store.curated_budget_state())["attempts_today"] == 0
finally:
await llm._client.close()
await store.close()


@pytest.mark.parametrize("review", [False, True])
@pytest.mark.parametrize("excess", [0, 1])
@curated_workflow()
async def test_profile_complete_request_boundary_before_reservation(
monkeypatch, tmp_path, review, excess,
):
_env(monkeypatch, CURATED_REQUEST_PROFILE="sol-astra-v1", CURATED_DEVELOPING_STORIES="true",
DAILY_TOKENS_CURATED="200000", DAILY_USD_CURATED="2.14")
store = await Store(str(tmp_path / "boundary.db")).open()
requests = []
schema = stories.REVIEW_RESPONSE_FORMAT if review else stories.DECISION_RESPONSE_FORMAT
messages = [{"role": "system", "content": "Evaluate source evidence."},
{"role": "user", "content": 'é"\\' * 1000}]
remaining = 32768 - curated_protocol.input_token_upper_bound(messages, schema)
messages[1]["content"] += "x" * (remaining + excess)
assert curated_protocol.input_token_upper_bound(messages, schema) == 32768 + excess

async def handle(request):
requests.append(request)
state = await store.curated_budget_state()
assert state["attempts_today"] == 1
assert state["reserved_tokens"] == 32768 + (8192 if review else 16384)
return httpx.Response(200, json=_profile_response(review=review))

llm = LLM(Settings(), store)
assert llm.curated_input_token_limit == 32768
await llm._client.close()
llm._client = AsyncOpenAI(
api_key="test", base_url="https://openrouter.ai/api/v1", max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle)),
)
try:
async def complete():
return await llm.complete(
"curated", messages, curated_story=not review, curated_review=review,
response_format=schema,
)

if excess:
with pytest.raises(CuratedRequestError, match="input context exceeds limit"):
await complete()
assert requests == []
state = await store.curated_budget_state()
assert state["attempts_today"] == state["workflows_today"] == state["used_tokens"] == 0
assert await store.get_meta("curated_profile_last_call") is None
else:
await complete()
assert len(requests) == 1
body = json.loads(requests[0].content)
assert body["messages"] == messages
assert body["response_format"] == schema
assert (await store.curated_budget_state())["attempts_today"] == 1
finally:
await llm._client.close()
await store.close()


@curated_workflow()
async def test_profile_fifth_call_in_one_workflow_is_denied(monkeypatch, tmp_path):
_env(monkeypatch, CURATED_REQUEST_PROFILE="sol-astra-v1", CURATED_DEVELOPING_STORIES="true")
Expand Down
Loading
Loading