Draft for implementation.
This design keeps clovapi focused on the local API proxy surface:
client -> http://127.0.0.1:{port}/{providerId}/v1/...
-> proxy router
-> protocol bridge
-> upstream provider
It does not bring back local tool configuration management. Clients still choose the local proxy URL and provider id themselves.
The current model is simple: a fixed provider id resolves to one persisted profile/model binding. Official subscriptions are represented as vendor profiles with one fixed credential location per provider.
That works for a single Codex subscription and a single Claude subscription, but it does not scale to:
- Multiple accounts for the same subscription provider.
- Failover between subscription accounts.
- Mixing official subscriptions and API-key upstreams for the same requested model.
- Routing by health, quota, latency, or preference.
- Clear per-call logs showing which real account/backend served a request.
- Allow multiple accounts per subscription provider.
- Keep API-key profiles, local providers, and subscription accounts routable through one backend abstraction.
- Preserve existing provider-id based proxy URLs.
- Add deterministic priority routing first.
- Add failover on retriable upstream failures.
- Record the selected backend in call logs without leaking secrets.
- Make the UI understandable without exposing a complex rules engine in the first version.
- Remote multi-tenant gateway behavior.
- Billing, rate limiting as a product feature, or shared admin dashboards.
- Automatic configuration of external CLIs.
- A full visual rules editor in the first implementation.
- Cross-user synchronization of credentials.
Important current pieces:
profile.Storepersistsprofiles.json.profile.Profilestores vendor-level settings and nestedModelrows.profile.Store.FlatProfileForProviderModel(...)resolves one model binding.proxyresolve.ResolveForwardRoute(...)resolves one outbound route.proxy.Server.handleProxy(...)prepares/transcodes and executes that route.- Subscription credentials are currently provider-level files, for example
subscription/codex.jsonandsubscription/claude.json. - Desktop UI already has a Profiles tab and a Models tab.
The smart router should be introduced between ResolveIngressContext and
ResolveForwardRoute, not inside the protocol bridge.
Represents one logged-in official subscription account.
{
"id": "sub_codex_personal",
"kind": "subscription_account",
"provider_id": "codex",
"label": "Codex Personal",
"credential_ref": "subscription/codex/personal.json",
"status": "active",
"plan": "Pro",
"models": [],
"created_at": "2026-07-12T00:00:00Z",
"updated_at": "2026-07-12T00:00:00Z"
}Notes:
- Credentials stay outside
profiles.jsonwhen they contain secrets. credential_refis a local reference, not credential material.- Account ids must be stable; display labels can change.
- Built-in single-account files should migrate into account records.
A concrete callable upstream candidate. It may come from:
- A nested API-key model under a profile.
- A subscription account model.
- A local provider model.
{
"id": "backend_codex_personal_gpt_5_5",
"source_type": "subscription",
"source_id": "sub_codex_personal",
"provider_id": "codex",
"model_id": "gpt-5.5",
"upstream_model": "gpt-5.5",
"api_style": "responses",
"enabled": true,
"priority": 100,
"weight": 1,
"health": {
"state": "unknown",
"last_error": "",
"last_checked_at": ""
}
}Route backends should initially be derived, not fully hand-authored. This keeps the first version simple and avoids a second source of truth.
An optional routing rule for a provider/model/style tuple.
{
"id": "route_codex_gpt_5_5",
"provider_id": "codex",
"match_model": "gpt-5.5",
"ingress_api_style": "responses",
"strategy": "priority_failover",
"backend_ids": [
"backend_codex_team_gpt_5_5",
"backend_codex_personal_gpt_5_5",
"backend_custom_api_gpt_5_5"
],
"enabled": true
}First version can skip explicit SmartRoute storage and derive the route from
backend priority. The data model should still leave room for explicit routes.
Increment profile.StoreVersion when implementation starts.
type Store struct {
Version int `json:"version"`
List []Profile `json:"profiles"`
Subscriptions []SubscriptionAccount `json:"subscriptions,omitempty"`
Routes []SmartRoute `json:"routes,omitempty"`
Proxy ProxyConfig `json:"proxy"`
}The current profiles array remains valid. This keeps existing users working.
On load:
- Keep existing profiles as-is.
- If a provider-level subscription credential exists and there is no matching
account yet, create a default account:
codex-defaultclaude-default
- Keep existing subscription vendor profiles as compatibility views.
- Keep old model rows and attach them to the default subscription account when a later implementation can do so safely.
No migration should delete credentials.
The router receives:
provider_idfrom URL path.- Requested model from the request body or
/models/{model}path. - Ingress API style from URL shape.
- Upstream path suffix.
Build candidate backends in this order:
- API profile model rows matching provider id and requested model id.
- Subscription account model rows matching provider id and requested model id.
- Local provider model rows matching provider id and requested model id.
- Default model for provider id, only when the request does not specify a model.
Matching rules:
- Exact model id match first.
- Exact upstream model match second.
- Optional alias match later.
First implementation:
enabled candidates
-> sort by priority asc
-> skip unhealthy candidates still in cooldown
-> choose first
If no candidate is available, return a clear route resolution error.
On upstream response:
Retry the next candidate when:
- Network error before response.
429500,502,503,504- Subscription credential expired errors that are recognizable without parsing secrets.
Do not retry when:
- Request body is invalid.
- The client disconnects.
- Upstream returns a deterministic model-not-found error for all candidates.
Retry policy:
- Maximum attempts: number of candidates, capped at 3 for v1.
- No retry for non-idempotent future endpoints unless explicitly allowed.
- Preserve original request body bytes so retries do not re-read a consumed stream.
Health should be lightweight and local:
type BackendHealth struct {
State string `json:"state"` // unknown, healthy, degraded, unhealthy
LastError string `json:"last_error,omitempty"`
LastStatus int `json:"last_status,omitempty"`
LastSeenAt time.Time `json:"last_seen_at,omitempty"`
CooldownUntil time.Time `json:"cooldown_until,omitempty"`
}Health can start in memory. Persisting it is optional and should not block v1.
Introduce a new package:
core/internal/router
Suggested API:
type ResolveInput struct {
ProviderID string
ModelID string
IngressStyle apistyle.Style
PathSuffix string
}
type Candidate struct {
BackendID string
ProviderID string
ModelID string
IngressStyle apistyle.Style
EgressStyle apistyle.Style
EffectiveModel string
Source string
APIKey string
AccountID string
BaseNormalized string
UpstreamURL string
}
type Plan struct {
Input ResolveInput
Candidates []Candidate
}proxy.Server should:
- Build a
router.Plan. - Iterate candidates.
- Prepare upstream request per candidate.
- Execute upstream request.
- Retry if the response/error is retryable.
- Log the final candidate and failover attempts.
proxyresolve.ResolveForwardRoute can remain as the compatibility path and be
wrapped by the new router during the transition.
Add non-secret route metadata:
type CallLogRoute struct {
BackendID string `json:"backendId,omitempty"`
SourceType string `json:"sourceType,omitempty"`
SourceID string `json:"sourceId,omitempty"`
SourceLabel string `json:"sourceLabel,omitempty"`
ProviderID string `json:"providerId,omitempty"`
RequestedModel string `json:"requestedModel,omitempty"`
UpstreamModel string `json:"upstreamModel,omitempty"`
AttemptCount int `json:"attemptCount,omitempty"`
AttemptBackends []string `json:"attemptBackends,omitempty"`
}UI should show this in the call log detail panel as:
- Requested model
- Served by
- Backend source
- Attempts
- Token usage
Keep the current tabs, but evolve content:
- Models: default operational view.
- Profiles: upstream account/profile management.
- Call logs: route observability.
Group sections:
-
Subscription accounts
- Add account
- Login/logout
- Refresh models
- Rename
- Delete
- Active/inactive status
-
API profiles
- Existing API-key profile management.
-
Local providers
- Ollama and future local engines.
Show routable model groups:
gpt-5.5
1. Codex Team / active / priority 10
2. Codex Personal / active / priority 20
3. Custom API / healthy / priority 100
First UI controls:
- Enable/disable backend.
- Move up/down priority.
- Copy model id.
- Test backend.
Avoid a full rules editor in v1.
Current fixed files:
~/.config/clovapi/subscription/codex.json
~/.config/clovapi/subscription/claude.json
Future layout:
~/.config/clovapi/subscription/codex/{account_id}.json
~/.config/clovapi/subscription/claude/{account_id}.json
Compatibility:
- Read old fixed files as default accounts.
- Write new accounts to per-account paths.
- Do not remove old files until logout/delete is explicit.
Keep existing proxy URLs.
Recommended future debug endpoints:
GET /__debug/routes
GET /__debug/routes?provider_id=codex&model_id=gpt-5.5
POST /__debug/routes/test
These endpoints are local/debug only and must not emit secrets.
- Add store fields for
subscriptionsand optionalroutes. - Add account id and credential ref helpers.
- Add migration for default subscription accounts.
- Add route/backend types without changing runtime routing yet.
- Add unit tests for migration and derived backend generation.
- Add
core/internal/router. - Derive candidates from existing profiles and subscription accounts.
- Replace single-route resolution in proxy with a plan containing one or more candidates.
- Keep behavior identical when only one candidate exists.
- Add tests for priority ordering and model matching.
- Retry across candidates on retryable failures.
- Add in-memory health state.
- Add call log route metadata.
- Add tests for 429/5xx/network failover.
- Add subscription account list.
- Add login flow that creates a selected account record.
- Add refresh models per account.
- Add model pool priority controls.
- Keep Profiles tab understandable for non-power users.
- Weighted routing.
- Least-used routing using token/call counters.
- Quota-aware routing from subscription/API usage snapshots.
- Latency-aware routing.
- Explicit route rule editor.
- Should the public provider id remain only
codex/claude, or should the user be able to expose account-specific provider ids? - How should aliases be represented: model-level alias rows or route-level match rules?
- Should health cooldown be persisted across proxy restarts?
- Should a failed subscription refresh disable only that backend or the whole account?
The first implementation PR should be intentionally small:
- Add the store structs.
- Add default account migration.
- Add a backend derivation function.
- Add tests.
- Expose
GET /__debug/routesfor inspection.
No failover and no UI mutation yet. That makes the storage and route model easy to review before it affects live calls.