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