1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
//! Core channel adapter contract.
use std::sync::Arc;
use bytes::Bytes;
use http::{HeaderMap, StatusCode};
use serde_json::Value;
use crate::context::{PrepareCtx, RefreshCtx, ShapeCtx, TransportKind};
use crate::control::{CredentialControlOperation, CredentialControlResponse};
use crate::disposition::Disposition;
use crate::error::ChannelError;
use crate::metadata::ChannelMetadata;
use crate::prepared::PreparedRequest;
use crate::transport::{ByteStreamDecoder as ChannelStreamDecoder, UpstreamClient};
use crate::usage::{RateLimitResetCreditConsumeResponse, UsageSnapshot, UsageWindowDescriptor};
/// A model catalogue plus the wire family used by its serialized body.
#[derive(Debug, Clone)]
pub struct ModelCatalog {
pub family: crate::protocol::Provider,
pub body: Bytes,
}
/// Pure upstream access adapter (§6.3). Implementors provide `id`,
/// `routing_table` and `prepare`; the rest have sensible defaults.
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
pub trait Channel: Send + Sync {
/// Stable channel id used as the registry key (matches `Provider.channel`).
fn id(&self) -> &'static str;
/// Metadata for runtime discovery and generic configuration UIs.
fn metadata(&self) -> ChannelMetadata {
ChannelMetadata::new(self.id())
}
/// The channel's explicit routing surface (ported from its capabilities).
fn routing_table(&self) -> crate::routes::RouteList;
/// Inject auth, resolve endpoint + method, set an ABSOLUTE upstream URL.
/// Pure access — no transform/rules, no body mutation. Moves `ctx.body` in.
fn prepare(&self, ctx: PrepareCtx<'_>) -> Result<PreparedRequest, ChannelError>;
/// Map an upstream response to the 5-state [`Disposition`]. Default is the
/// generic HTTP-status mapping; override only for provider-specific signals.
/// For streaming, `body` is empty (status + headers suffice).
fn classify(&self, status: StatusCode, headers: &HeaderMap, _body: &Bytes) -> Disposition {
Disposition::from_http(status, headers)
}
/// Whether a model-bound auth rejection (401/402/403) kills the WHOLE
/// credential rather than only the exact (credential, model) pair. `true`
/// for subscription-account channels (codex, claudecode) whose token is
/// account-wide. Default: model-scoped.
fn credential_wide_auth(&self) -> bool {
false
}
/// Whether first-time cookie exchange must use the native browser profile.
fn cookie_login_requires_browser(&self) -> bool {
false
}
/// Whether refreshing this secret must use the native browser profile.
fn refresh_requires_browser(&self, _secret: &Value) -> bool {
false
}
/// Whether this model draws from the channel's account-wide MAIN quota
/// pool. The main limit governs the whole account, so a 429 here cools the
/// WHOLE credential (separate-limit models included). Models with an
/// ADDITIONAL scoped limit on top of the main pool (codex spark, claude
/// fable) return `false`: their own 429 means only the scoped limit is hit
/// and stays model-scoped. Default: `false` (per-model quota, api-key
/// channels).
fn shares_account_quota(&self, _upstream_model_id: &str) -> bool {
false
}
/// Channel-specific REQUEST-body shaping (整形): runs after protocol
/// transform + process rules, before [`prepare`](Channel::prepare). Pure
/// field hygiene (strip unsupported fields, cap/rename, role/tools
/// normalize, remove header tokens). Default: identity.
fn shape_request(&self, body: Bytes, _headers: &mut HeaderMap, _ctx: &ShapeCtx) -> Bytes {
body
}
/// Channel-specific RESPONSE-body shaping (整形) on the raw buffered upstream
/// body, before protocol transform. Operation-aware via `ctx` so a channel
/// can reshape model lists, fix non-standard fields, unwrap envelopes, etc.
/// Runs on ALL statuses (error bodies included). Default: identity.
fn shape_response(&self, body: Bytes, _ctx: &ShapeCtx) -> Bytes {
body
}
/// A channel-bundled static model catalogue, for channels whose upstream
/// exposes no model-list endpoint (e.g. vertexexpress). When `Some`, the
/// admin model-pull returns it directly — no credential / upstream call. The
/// returned catalogue identifies its own canonical model-list wire family.
/// Default: none.
fn bundled_models(&self) -> Option<ModelCatalog> {
None
}
/// A credential-scoped model catalogue discovered while authenticating or
/// refreshing the secret. Unlike [`bundled_models`](Self::bundled_models),
/// this hook is evaluated only after the credential has been decrypted and
/// refreshed, so account-specific catalogues can be returned without an
/// extra upstream model-list request. Default: none.
fn credential_models(&self, _secret: &Value) -> Option<ModelCatalog> {
None
}
/// Optional channel-specific stream decoder (envelope unwrap / binary →
/// SSE), applied to the raw upstream byte stream before any protocol
/// transform. Default: none (passthrough).
fn stream_decoder(&self) -> Option<Box<dyn ChannelStreamDecoder>> {
None
}
/// Whether the DECRYPTED secret must be refreshed before use (e.g. OAuth
/// access token near expiry). Default: never.
fn needs_refresh(&self, _secret: &Value) -> bool {
false
}
/// Refresh the credential against the provider, returning the new PLAINTEXT
/// secret Value. The pipeline re-seals + persists + publishes — the channel
/// never touches cipher/persistence (purity §6.3). Default: unsupported.
async fn refresh(
&self,
_client: &Arc<dyn UpstreamClient>,
_ctx: RefreshCtx<'_>,
) -> Result<Value, ChannelError> {
Err(ChannelError::Unsupported("refresh"))
}
fn transport(&self) -> TransportKind {
TransportKind::Http
}
/// Build one credential-scoped account/control request. The default bridges
/// the two legacy usage/reset hooks so existing channels keep working; new
/// account operations opt in explicitly per channel.
fn prepare_credential_control_request(
&self,
operation: &CredentialControlOperation,
secret: &Value,
settings: &Value,
) -> Result<Option<http::Request<Bytes>>, ChannelError> {
match operation {
CredentialControlOperation::Usage => self.prepare_usage_request(secret, settings),
CredentialControlOperation::ConsumeRateLimitResetCredit { idempotency_key } => {
self.prepare_rate_limit_reset_credit_request(secret, settings, idempotency_key)
}
_ => Ok(None),
}
}
/// Parse a response to [`prepare_credential_control_request`].
fn parse_credential_control_response(
&self,
operation: &CredentialControlOperation,
status: StatusCode,
headers: &HeaderMap,
body: &Bytes,
) -> Option<CredentialControlResponse> {
match operation {
CredentialControlOperation::Usage => self
.parse_usage(status, headers, body)
.map(CredentialControlResponse::Usage),
CredentialControlOperation::ConsumeRateLimitResetCredit { .. } => self
.parse_rate_limit_reset_credit(status, headers, body)
.map(CredentialControlResponse::RateLimitResetCreditConsume),
_ => None,
}
}
/// Build a request to this channel's per-credential upstream usage / quota
/// endpoint, given an already-fresh decrypted `secret` and provider
/// `settings`. `None` (the default) means the channel exposes no usage
/// endpoint (api-key / vertex channels). The driver sends it through the
/// credential's resolved client (same proxy + TLS profile as traffic) and
/// feeds the response to [`parse_usage`](Channel::parse_usage). Pure access:
/// no persistence, no body shaping beyond what the endpoint needs.
fn prepare_usage_request(
&self,
_secret: &Value,
_settings: &Value,
) -> Result<Option<http::Request<Bytes>>, ChannelError> {
Ok(None)
}
/// Parse this channel's usage-endpoint response into the normalized
/// [`UsageSnapshot`]. Called only with the response to the request from
/// [`prepare_usage_request`](Channel::prepare_usage_request). `None` on a
/// non-success status or an unparseable body.
fn parse_usage(
&self,
_status: StatusCode,
_headers: &HeaderMap,
_body: &Bytes,
) -> Option<UsageSnapshot> {
None
}
/// Describe the stable identity, scope, meter and period boundary of one
/// normalized usage window. The host calls this only with an index from
/// `snapshot.windows`; the conservative default keeps existing external
/// channel implementations source-compatible.
fn describe_usage_window(
&self,
snapshot: &UsageSnapshot,
index: usize,
) -> UsageWindowDescriptor {
snapshot
.windows
.get(index)
.map(UsageWindowDescriptor::from_window)
.unwrap_or_else(|| {
UsageWindowDescriptor::from_window(&crate::usage::UsageWindow {
name: format!("window_{index}"),
..Default::default()
})
})
}
/// Build a request to consume one earned rate-limit reset credit. Only
/// channels whose upstream exposes this account action return a request.
fn prepare_rate_limit_reset_credit_request(
&self,
_secret: &Value,
_settings: &Value,
_idempotency_key: &str,
) -> Result<Option<http::Request<Bytes>>, ChannelError> {
Ok(None)
}
/// Parse the response from
/// [`prepare_rate_limit_reset_credit_request`](Self::prepare_rate_limit_reset_credit_request).
fn parse_rate_limit_reset_credit(
&self,
_status: StatusCode,
_headers: &HeaderMap,
_body: &Bytes,
) -> Option<RateLimitResetCreditConsumeResponse> {
None
}
}