Cutting OAuth, Keeping Two Protocols: The Trade-offs of an Access Convergence
An Open Council postmortem: from a wide access matrix of CLI / OAuth / subscription / middleware down to just anthropic + openai standard APIs. Why subtract, and how to subtract honestly.
Open Council is a local multi-model debate system that needs to connect several model vendors at once. Early on, to “connect anything,” it propped up a very wide access matrix: CLI subprocesses, OAuth subscription logins, family mappings for 20-plus providers, all glued together by one middleware dependency.
That whole matrix was later cut, leaving only two standard API protocols: anthropic (@anthropic-ai/sdk) and openai (the openai SDK). This post is about why do that subtraction, and how to make the subtraction honest.
How wide the access surface was, before
Before convergence, a middleware layer, pi-ai, held up an entire apparatus:
- CLI channel: spawning subprocesses,
SIGTERM → SIGKILLteardown, parsing codex’s JSONL, EPIPE guards on stdin, and on top of it a layer orchestrating “API first, fall back to CLI”; - OAuth / subscription: a whole set of
discoverOAuthCredentials/readCodexAuthFile/readClaudeCodeKeychain, reusing the subscription logins of Claude Code, Codex, Gemini; - 20+ provider family mappings:
RELATED_PROVIDERS,LEGACY_TO_PIAI,GOOGLE_FAMILY,PROVIDER_PRIORITY— a pile of tables doing fuzzy registry matching.
Feature-wise it was complete. The problem is it was complete without a center.
Why cut it
Three reasons, from most concrete to most engineering:
- Subscription-quota access is low-quality and unstable. This is the first motive, not fastidiousness — riding subscription quota via OAuth / CLI is worse in both response quality and stability than hitting a standard API directly. For a debate system meant to produce “reliable answers,” an unstable underlying access path is fatal;
- A big dependency used for scraps.
pi-ai’s core value is “20+ provider adaptation + OAuth” — exactly the parts being cut. After the cut, keeping the dependency only to use its two-protocol invoke means carrying a big dependency for its scraps; - Dropping the middleware loses no capability.
pi-aiinternally already wraps the@anthropic-ai/sdkandopenaiofficial SDKs. Peel off the middleware and use the official SDKs directly — not one bit of capability lost, and in return you get structured error objects and nativeAbortSignalback.
The endpoint of convergence is clean: only two wire protocols, credentials being just “an API key (env var or 0o600 key file) + optional base_url,” and no more OAuth logins, token refresh, keychain reads, or CLI subprocesses at all.
Swap the engine, but don’t demolish the house
The most important discipline: not a single character of the contract to the upper layer changes.
The core layer calls the backend through the InvocationAdapter { invoke, healthCheck } interface, and convergence locked every breaking change inside the implementation — delete the CLI adapter, swap in the official SDK internally — while the invoke / healthCheck signatures didn’t budge. The result: the core layer changed nothing, and hundreds of tests backstop the behavioral semantics staying the same.
The design doc even turned this into an acceptance signal: if the core-layer tests break because of this refactor, it means a CLI assumption leaked into the core. A cleanly drawn interface boundary keeps “swap the engine” from becoming “demolish the whole house.”
Connecting compatible endpoints collapses to one line as a result — a single ?? in the factory:
const baseURL = config.base_url ?? OFFICIAL_BASE_URL[config.protocol];
Leave base_url unset and it goes to the official endpoint; set it and it’s passed straight to the SDK. DeepSeek, Moonshot, Ollama, vLLM, LM Studio compatible endpoints all connect through this one override, without changing a line of code.
A counterintuitive insistence: turn the SDK’s retries off
The official SDKs default to maxRetries: 2, automatically retrying failed requests. The first thing after convergence was, instead, to turn it down to maxRetries: 0 and carry retries yourself.
Why not take the easy route and let the SDK retry? Because a hand-rolled retry has to coordinate three things the SDK can’t see:
- Once a stream has emitted text, no retry — a mid-stream failure retried would emit the text twice; a closure counter
emittedallows a retry only whenemitted === 0; - The circuit breaker must record exactly one failure — an SDK retrying quietly and succeeding hides a real failure from the breaker and skews the health stats;
- Adaptive throttling must key off real successes and failures.
Let the SDK retry too, and you get double retries, with the failure signal hidden. When your upper logic depends on every failure being recorded faithfully, you can’t let the layer below retry behind your back.
A bonus: after cutting the middleware, error classification flipped from “string keyword matching” to “read .status on the main path” — the official SDKs throw structured APIError (with status) and a dedicated RateLimitError, so 429/5xx/408 are retryable, 4xx permanent, and string matching demotes to a fallback only for compatible gateways’ bare text. Once you swap in a faithful backend, you should promptly delete the defensive code you wrote back when the backend wasn’t faithful.
The honesty of migration: disable + annotate, not pretend to convert
The old config had a pile of models depending on OAuth / CLI / subscription. Bumping the schema from v1 to v2, the migration’s iron rule was fixed as three clauses: never hard-error, never silently drop, never fabricate a model doomed to fail.
The migration classifies by 8 priorities: already-v2 formats pass through untouched; custom endpoints with base_url get their protocol guessed from the URL and carried over; models whose provider is anthropic/openai and that have a usable key convert to official endpoints; while those depending on the Google family, Copilot, CLI, or simply lacking a key are all disabled and annotated with a reason, not deleted.
Why not just auto-convert, or drop them outright? The line in the design doc says it best: you cannot conjure a usable API key out of nowhere; an automatic “conversion” only produces a model doomed to fail — disable plus clear guidance is more honest. A disabled model still appears in council models with its reason (for example, handing you Google’s compatible-endpoint address directly), and adding the key re-enables it. The old config is even backed up to .v1.bak before writing back, and the whole migration touches no disk — pure decision-making, with reads and writes handed to the upper layer, so a failed migration just retries on next load. Idempotent.
Takeaways
A refactor converging a wide access matrix down to two standard protocols:
- The reason to subtract must be concrete: subscription access is low-quality and unstable, a big dependency used for scraps, no capability lost dropping the middleware — not simplicity for simplicity’s sake;
- Swap the engine, don’t demolish the house: lock the
InvocationAdaptercontract, keep breaking changes in the implementation, zero core changes, tests backstopping the semantics; - Don’t let the layer below retry behind your back:
maxRetries: 0and carry it yourself, because stream dedup, breaker counting, and adaptive throttling all key off real outcomes; - Delete the defenses written for the old backend: with a faithful official SDK in place, string error matching and racing timeout fallbacks can go;
- Migrate honestly: disable + annotate the unconvertible models, never fabricate one that’s guaranteed to fail.
In one line: convergence isn’t shortening the feature list — it’s getting clear on what deserves long-term maintenance; everything you keep has to earn the complexity it costs.
Comments