Skip to content

Adding PerchAI provider - #130

Closed
kevincojean wants to merge 66 commits into
b3nw:devfrom
kevincojean:feat/provider-app.perchai
Closed

kevincojean wants to merge 66 commits into
b3nw:devfrom
kevincojean:feat/provider-app.perchai

Conversation

@kevincojean

@kevincojean kevincojean commented Aug 20, 2026

Copy link
Copy Markdown

[DRAFT] Things are not yet ready for merge or review. Will test for a few days.


Added provider perchai.app
Using oauth tokens

Cheers

@kevincojean kevincojean changed the title Adding provider 'perchai.app' Adding PerchAI provider Aug 20, 2026
@kevincojean

Copy link
Copy Markdown
Author

[DRAFT] As of commit b8fc9f0 which I consider to be release candidate 1: things are not yet ready for merge or review. Will test for a few days.

- Custom acompletion: envelope-shaped request body, SSE stream parsing, finish_reason='stop' mapping
- OAuth session reader: file-based refresh with asyncio.Lock single-flight, in-memory TTL cache
- $150/mo rolling quota tracker with model-level breakdown and warning/error thresholds
- PerchaiAuthBase: cookie+state-file OAuth flow shared across provider surface
- ProviderFactory registers perchai auth class alongside existing OAuth providers
- All 10 perchai error codes mapped to existing provider error taxonomy
- .fork/stack.yml: perchai feature entry registered for check-stack validation
- .fork/features/perchai.md: append-only ledger entry for durable shared history
- Note: tests/test_perchai_provider.py lives on disk but is fork-level gitignored
  by the tests/* rule; live e2e coverage runs outside the repo
…AI response shape

Litellm APIError (base class) does not accept response= kwarg; requires
status_code= and accepts request=httpx.Request (not Response). Fixed 3 callsites
(empty body, malformed JSON, missing choices).

Perchai's /api/perch-terminal/model-call returns
{ok, text, content, provider, model, usage:{inputTokens, outputTokens,
cacheReadInputTokens, totalTokens}} — NOT OpenAI-compatible {choices:[...]}.
Added adapter that walks perchai's shape and builds litellm.ModelResponse
manually with litellm.Choices/Message/Usage.

Live test on perchai/nemotron-3.5-lightning (non-streaming) returns 'Hello'
with usage mapped correctly; streaming yields 3 chunks ('1','2\n3\n4\n5',
finish=stop); quota background job polls /api/perch-terminal/usage without
crash; 29/29 pytest cases pass.
The streaming path (_stream_completion) had no 401 handler - it raised
AuthenticationError immediately on token expiry. With a single credential,
the proxy exhausted all credentials and failed the request.

The non-streaming path already had reactive 401 refresh via
PerchaiAuthBase.refresh_on_401 + asyncio.Lock single-flight. This commit
mirrors that pattern in the streaming path:

- _stream_completion now accepts build_headers (callable), token,
  auth_base_cls, auth_error_cls instead of a pre-built headers dict
- On first 401: consume body, exit stream context, refresh token via
  refresh_on_401, retry stream with new token
- On second 401 (refresh failed): let _raise_for_status raise
- Uses manual __aenter__/__aexit__ to enable retry across context
  manager boundaries inside an async generator

Test coverage (tests/test_perchai_provider.py, gitignored but on disk):
- test_given_expired_token_when_non_stream_chat_then_refreshes_and_retries: PASS
- test_given_expired_token_when_stream_chat_then_refreshes_and_retries: PASS
- 31/31 total suite pass, zero regressions
…ase GoTrue /auth/v1/token?grant_type=refresh_token
- Drop typing_extensions.AsyncGenerator alias; import from typing directly
- Move lazy 'import time' to module top of perchai_quota_tracker
- Replace __import__('os') in __init__ with module-level 'import os'
- Convert absolute rotator_library.* imports to relative (.. / ...)
- Register 'perchai' pytest marker in pyproject.toml
…n files

Audit pass: removed every verbose docstring, section-divider banner, and
describe-what-the-code-obviously-does comment from the four PR Python
files. Kept only comments that explain non-obvious contract details
(canonical tool-call source, single-flight refresh, atomic session
write, lazy asyncio.Lock, defensive speculative branches).

Module docstrings, TypedDict docstrings, and class docstrings were
removed entirely; their names are self-documenting. Function docstrings
were dropped from names whose behaviour is evident from the body.

Net change: -708 lines across 4 files.

Also fix: Copyright header in tests/test_perchai_provider.py now reads
'Kévin Cojean' (was 'b3nw').
…Cojean

The three new files (perchai_provider.py, perchai_auth_base.py,
perchai_quota_tracker.py) were authored for this fork and shouldn't
inherit the upstream LGPL. Re-license as MIT and set copyright to the
file author.

Upstream files in this PR (provider_urls.py, credential_manager.py,
provider_config.py, provider_factory.py) keep their existing Mirrowel
copyright since most of their content is upstream-owned.

Test file copyright was already fixed in dc2ed33.
fix(provider): resolve credential identifier to access token before use
test: verify credential file path resolution returns correct token
test: verify env:// virtual
chore(build): copy requirements.txt during build process
fix(auth): handle JSON decode error in token refresh error handling
…g stop

Perchai provider detected incomplete streams (no [DONE] marker) but only
logged at DEBUG level and synthesized a stop chunk, making the client
think the response was complete. This caused silent truncation during
long thinking chains.

Changes:
- Change log level from DEBUG to WARNING for visibility
- Raise RuntimeError instead of synthesizing stop chunk
- Client now receives error instead of partial response

Tests:
- test_perchai_stream_normal_termination
- test_perchai_stream_incomplete_raises_error

Fixes kanban T-001 (Perchai provider path)
Track stream duration, chunk count, and bytes received when stream
ends without [DONE] marker. This helps diagnose whether truncation
correlates with time, data size, or other patterns.
Perchai API sends two completion signals:
1. {"type": "done", "finishReason": "stop"} event
2. data: [DONE] SSE terminator

Previously only checked for [DONE], causing false truncation errors
when Perchai sent done event without [DONE] terminator.

Now accepts either signal as valid completion.

Fixes kanban T-001
Perchai streams ending without [DONE] or done event is normal for tool
calls. Changed log level from WARNING to DEBUG and rephrased message to
be informational rather than alarmist.

Fixes kanban T-001
@kevincojean

Copy link
Copy Markdown
Author

[DRAFT] As of commit 846b6a9 which I consider to be release candidate 2: fixed a pretty tricky issue which would interrupt streaming (normal responses and thinking responses). Things seem stable for deepseek, will test for more time with other models to confirm stability before submitting PR.

test(perchai_provider): add tests for sync transform_request hook execution
test(perchai_provider): verify reasoning_effort is capped to
When Perch.ai sends done event with ok=false, the error was logged at
DEBUG level and silently dropped (return None). This caused streams to
end prematurely without visible error, appearing as 'thinks half second
then interrupts'.

Changed to:
- Log at WARNING level (visible in normal logs)
- Raise RuntimeError with actual error message
- Error propagates to streaming handler for proper retry/rotation

Updated test to expect RuntimeError instead of None return.
@kevincojean

kevincojean commented Sep 1, 2026

Copy link
Copy Markdown
Author

Found out Perchai sets hard limits on thinking tokens for DeepSeek v4 flash. Added a solution to this as of 32dfe05 will be testing again. Looking good - getting good results with Gemma models, Qwen 3.8 flash, and lately the deepseek v4 flash (which has been the most capricious) is behaving much better.

Perch now gates /api/perch-terminal/model-call behind a short-lived
x-perch-turn-ticket header minted via /api/perch-terminal/turn-ticket,
which the proxy never sent - the reported 403 perch_surface_required
credential exhaustion. Add PerchaiAuthBase.ensure_turn_ticket() to
mint/cache/renew a ticket per credential and attach it in acompletion.
On a 403 perch_surface_required mid-flight, drop the cached ticket,
re-mint, and retry once (mirrors the existing 401 retry-once pattern)
instead of raising and burning the credential. Classify the new
turn_rate_limited 429 as real per-turn exhaustion.
test_option_id_routes_to_real_upstream crafts its model-call by hand
instead of going through PerchaiProvider.acompletion(), so it predates
the turn-ticket requirement and now 403s with perch_surface_required
against the live service. Mint and attach the ticket the same way the
provider does.
@kevincojean
kevincojean force-pushed the feat/provider-app.perchai branch from 98600ac to 85216bb Compare September 3, 2026 08:30
…ions and CLI User-Agent mirroring

Two coordinated fixes to the operator's 'reauth SUPER frequently + 403
perch_surface_required' report:

1. Email/password credential discovery (feat-style):
   PERCHAI_EMAIL_<N>/PERCHAI_PASSWORD_<N> env vars mint independent
   GoTrue sessions at <data_dir>/oauth_creds/perchai_password_<N>.json.
   The proxy now has its own refresh chain and never shares the CLI's
   single-use rotating token, never reads or writes ~/.perch.

2. User-Agent mirroring on every outbound Perch request (fix-style):
   The CLI bundle sets User-Agent: perchai-cli/<PERCHAI_CLI_VERSION||'unknown'>
   on every outbound call. The proxy was sending httpx's default
   'python-httpx/<ver>' which Perch's server fingerprints as 'direct API
   access' and rejects with perch_surface_required. Mirrored the CLI's
   exact pattern: USER_AGENT_PREFIX='perchai-cli/', USER_AGENT_VERSION_ENV
   ='PERCHAI_CLI_VERSION', USER_AGENT_VERSION_FALLBACK='unknown'. Header
   now set on turn-ticket mint, model-call, account GET, Supabase config
   GET, GoTrue refresh, and password sign-in.

Both fixes target the same root cause: the proxy not appearing as the
CLI to Perch's server. With both deployed, the proxy owns its identity
(separate chain, no shared refresh) and wears the CLI's fingerprint
(so it is not classified as direct API access).

Files changed:
- src/rotator_library/credential_manager.py - discover
  PERCHAI_EMAIL_<N>/PERCHAI_PASSWORD_<N>, expose as
  password://perchai/<N> virtual credential paths
- src/rotator_library/providers/perchai_auth_base.py -
  PerchaiCredentialKind.PASSWORD, _ensure_password_session,
  _sign_in_with_password, password-mode session cache routing, and
  USER_AGENT_* constants plus _user_agent()/user_agent()
- src/rotator_library/providers/perchai_provider.py - User-Agent in
  model-call _headers() and account GET
- tests/test_credential_manager.py - regression test for the new
  discovery
- tests/test_perchai_auth.py - 7 UA RED/GREEN tests (turn-ticket, env
  override, password signin, refresh, config GET, model-call, helper
  unit), plus prior-session password bootstrap tests
- .fork/features/perchai.md - ledger entries for both fixes

Verification:
  uv run python3 -m py_compile <changed files>   # exit 0
  uv run ruff check <changed files> --select F401,F811,F821,E9   # clean
  uv run pytest tests/test_perchai_auth.py tests/test_perchai_provider.py -q -m 'not live'   # 127 passed
  uv run pytest tests/ -q -m 'not live'   # 3 failed (live-gated, pre-existing Perch-side 403), 648 passed, 15 errors - no new breakage from prior run (was 3/637/15)

Live confirmation of the password bootstrap path (PUT /auth/v1/user then
POST /auth/v1/token?grant_type=password returning HTTP 200 with an
independent session_id) is in .opencode/ledger/perchai-auth.md.
The User-Agent fix is correct but cannot be live-confirmed until the
branch is deployed and tested against app.perchai.app, since the real
perch CLI on this machine is currently also 403'd by Perch's gate.
@kevincojean
kevincojean force-pushed the feat/provider-app.perchai branch from 85216bb to c225465 Compare September 3, 2026 09:19
… unblock

Three live probes were silently 403'd by Perch's surface gate because they
used raw httpx.AsyncClient without setting User-Agent (httpx defaults to
python-httpx/<ver>, which Perch fingerprints and rejects with
perch_surface_required regardless of which endpoint it hits). The deployed
proxy already sends perchai-cli/<version> on every outbound call - the
seam test in test_perchai_auth.py proves it. These tests now send the
same header through the same PerchaiAuthBase.user_agent() helper.

Also adds test_live_provider_acompletion_returns_200_against_app_perchai:
the first e2e that goes through PerchaiProvider.acompletion against the
live app.perchai.app. The seam test proves the wire-level UA; this test
proves production accepts it end to end (token discovery -> turn-ticket
mint -> model-call POST). If the UA fix is ever reverted, the turn-ticket
mint 403s with perch_surface_required and acompletion raises
PerchaiAuthError before any model call is attempted.
@kevincojean

Copy link
Copy Markdown
Author

As of commit 207e245 things are quite stable, the most difficult part is keeping up with the quasi-daily changes Perch makes to their backend - it's quite fast moving. In that sense I feel like this provider cannot be considered stable any time soon and would rather archive the PR. Though the code will stay up for any curious mind.

@kevincojean kevincojean closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant