Skip to content

Commit 7c9ab68

Browse files
authored
feat(provider): add new provider gateway types (#14)
1 parent ed48723 commit 7c9ab68

9 files changed

Lines changed: 2136 additions & 0 deletions

File tree

docs/internals/llm-types.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# LLM Types Deep Dive
2+
3+
This document describes the Layer 1 type system for the LLM subsystem. It covers the wire models for the supported chat APIs plus the shared metadata and error types used by later bridge and provider code.
4+
5+
## Namespace boundaries
6+
7+
The OpenAI namespace owns both Chat Completions and Responses. They are related APIs from the same vendor, but they are not peers of Anthropic types.
8+
9+
## OpenAI Chat as the hub format
10+
11+
The types in `gateway::types::openai` are both the OpenAI Chat Completions wire models and the internal hub format used by the N:M bridge architecture.
12+
13+
Three choices matter here:
14+
15+
- `ChatCompletionRequest.extra` uses `#[serde(flatten)]` so provider-specific fields can survive round-trips without polluting the hub model.
16+
- `MessageContent` is untagged because OpenAI accepts either a plain string or a structured content array.
17+
- `StopCondition` is untagged because the API accepts either a single stop string or a list.
18+
19+
That keeps the hub representation close to the public OpenAI schema while still being permissive enough for bridge code.
20+
21+
## OpenAI Responses under the OpenAI namespace
22+
23+
The Responses API types now live in `gateway::types::openai::responses`.
24+
25+
That module models the parts that are specific to Responses rather than Chat Completions:
26+
27+
- polymorphic input (`ResponsesInput`)
28+
- built-in OpenAI tools (`ResponsesTool`)
29+
- richer output items (`ResponsesOutputItem`)
30+
- fine-grained SSE event types (`ResponsesApiStreamEvent`)
31+
32+
Keeping these types under the OpenAI namespace avoids presenting `responses` as a top-level peer alongside provider-agnostic or vendor-level modules.
33+
34+
## Anthropic message models
35+
36+
`gateway::types::anthropic` stays separate because it describes a different vendor protocol.
37+
38+
Its main differences from the hub format are:
39+
40+
- system prompts are top-level, not embedded in the message list
41+
- content blocks are internally tagged by `type`
42+
- streaming uses event-specific records instead of a single chunk envelope
43+
- prompt caching metadata is part of the native schema
44+
45+
## Shared bridge metadata
46+
47+
`gateway::types::common::BridgeContext` carries information that cannot be represented cleanly in the hub request alone.
48+
49+
It currently has three buckets:
50+
51+
- `anthropic_messages_extras` for Anthropic Messages-specific request data
52+
- `openai_responses_extras` for OpenAI Responses-specific state such as `previous_response_id`
53+
- `passthrough` for arbitrary provider-specific values
54+
55+
This lets future `to_hub()` implementations return a normalized hub request without losing format-specific data that must be restored later.
56+
57+
## Unified usage accounting
58+
59+
`gateway::types::common::Usage` is intentionally sparse: every field is optional.
60+
61+
That matches real provider behavior. Some providers report only prompt tokens, some only final totals, and some stream usage late. `Usage::merge()` therefore follows two rules:
62+
63+
- overwrite only fields that are present in the incoming value
64+
- derive `total_tokens` only when it was not explicitly provided and both prompt and completion counts exist
65+
66+
The tests cover overwrite behavior, derived totals, and preservation of explicit totals.
67+
68+
## GatewayError
69+
70+
`gateway::error::GatewayError` is the common error surface for the LLM subsystem.
71+
72+
It separates four concerns:
73+
74+
- client-side request problems (`Validation`, `Bridge`)
75+
- data conversion problems (`Transform`)
76+
- upstream/provider failures (`Provider`, `Http`)
77+
- stream lifecycle failures (`Stream`)
78+
79+
Two helper methods make this usable from higher layers:
80+
81+
- `is_retryable()` centralizes retry policy
82+
- `status_code()` maps failures to proxy-facing HTTP status codes
83+
84+
This keeps the later LLM runtime and proxy code from duplicating provider error classification logic.

src/gateway/error.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Gateway error types.
2+
//!
3+
//! `GatewayError` is the unified error type for the gateway SDK layer
4+
//! (Layer 1-3). It covers validation errors, format bridging errors,
5+
//! provider HTTP errors, and stream errors. Each variant carries enough
6+
//! context for the proxy layer to produce an appropriate HTTP response.
7+
8+
use http::StatusCode;
9+
use serde_json::Value;
10+
11+
/// Unified error type for the gateway SDK.
12+
#[derive(Debug, thiserror::Error)]
13+
pub enum GatewayError {
14+
// ── Client errors (not retryable) ──
15+
/// Request validation failed (e.g., missing required field).
16+
#[error("validation: {0}")]
17+
Validation(String),
18+
19+
/// Format bridging failed (e.g., cannot map an Anthropic field to hub format).
20+
#[error("format bridge: {0}")]
21+
Bridge(String),
22+
23+
/// Data transformation failed (e.g., JSON deserialization of provider response).
24+
#[error("data transform: {0}")]
25+
Transform(String),
26+
27+
/// The requested format is not natively supported by the provider.
28+
#[error("format not natively supported by provider {provider}")]
29+
NativeNotSupported { provider: String },
30+
31+
// ── Provider errors (may be retryable) ──
32+
/// The upstream provider returned an error response.
33+
#[error("provider {provider} returned {status}: {body}")]
34+
Provider {
35+
status: StatusCode,
36+
body: Value,
37+
provider: String,
38+
retryable: bool,
39+
},
40+
41+
// ── Infrastructure errors (usually retryable) ──
42+
/// HTTP transport error (connection, timeout, etc.).
43+
#[error("HTTP: {0}")]
44+
Http(#[source] reqwest::Error),
45+
46+
/// Error during stream processing.
47+
#[error("stream: {0}")]
48+
Stream(String),
49+
}
50+
51+
impl GatewayError {
52+
/// Whether this error is safe to retry.
53+
pub fn is_retryable(&self) -> bool {
54+
match self {
55+
Self::Provider { retryable, .. } => *retryable,
56+
Self::Http(e) => e.is_timeout() || e.is_connect(),
57+
Self::Stream(_) => true,
58+
_ => false,
59+
}
60+
}
61+
62+
/// Map to an HTTP status code for proxy-layer responses.
63+
pub fn status_code(&self) -> StatusCode {
64+
match self {
65+
Self::Validation(_) | Self::Bridge(_) => StatusCode::BAD_REQUEST,
66+
Self::Transform(_) => StatusCode::UNPROCESSABLE_ENTITY,
67+
Self::Provider { status, .. } => *status,
68+
Self::Http(_) | Self::Stream(_) => StatusCode::BAD_GATEWAY,
69+
Self::NativeNotSupported { .. } => StatusCode::NOT_IMPLEMENTED,
70+
}
71+
}
72+
}
73+
74+
/// Convenience alias for gateway results.
75+
pub type Result<T> = std::result::Result<T, GatewayError>;
76+
77+
#[cfg(test)]
78+
mod tests {
79+
use serde_json::json;
80+
81+
use super::*;
82+
83+
#[test]
84+
fn validation_not_retryable() {
85+
let e = GatewayError::Validation("missing field".into());
86+
assert!(!e.is_retryable());
87+
assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
88+
}
89+
90+
#[test]
91+
fn bridge_not_retryable() {
92+
let e = GatewayError::Bridge("cannot map field X".into());
93+
assert!(!e.is_retryable());
94+
assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
95+
}
96+
97+
#[test]
98+
fn transform_not_retryable() {
99+
let e = GatewayError::Transform("bad json".into());
100+
assert!(!e.is_retryable());
101+
assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
102+
}
103+
104+
#[test]
105+
fn native_not_supported() {
106+
let e = GatewayError::NativeNotSupported {
107+
provider: "gemini".into(),
108+
};
109+
assert!(!e.is_retryable());
110+
assert_eq!(e.status_code(), StatusCode::NOT_IMPLEMENTED);
111+
assert!(e.to_string().contains("gemini"));
112+
}
113+
114+
#[test]
115+
fn provider_retryable_when_flagged() {
116+
let e = GatewayError::Provider {
117+
status: StatusCode::TOO_MANY_REQUESTS,
118+
body: json!({"error": "rate limited"}),
119+
provider: "openai".into(),
120+
retryable: true,
121+
};
122+
assert!(e.is_retryable());
123+
assert_eq!(e.status_code(), StatusCode::TOO_MANY_REQUESTS);
124+
}
125+
126+
#[test]
127+
fn provider_not_retryable_when_not_flagged() {
128+
let e = GatewayError::Provider {
129+
status: StatusCode::BAD_REQUEST,
130+
body: json!({"error": "bad request"}),
131+
provider: "anthropic".into(),
132+
retryable: false,
133+
};
134+
assert!(!e.is_retryable());
135+
assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
136+
}
137+
138+
#[test]
139+
fn stream_error_retryable() {
140+
let e = GatewayError::Stream("connection reset".into());
141+
assert!(e.is_retryable());
142+
assert_eq!(e.status_code(), StatusCode::BAD_GATEWAY);
143+
}
144+
145+
#[test]
146+
fn display_messages() {
147+
assert_eq!(
148+
GatewayError::Validation("x".into()).to_string(),
149+
"validation: x"
150+
);
151+
assert_eq!(
152+
GatewayError::Bridge("y".into()).to_string(),
153+
"format bridge: y"
154+
);
155+
let provider_err = GatewayError::Provider {
156+
status: StatusCode::INTERNAL_SERVER_ERROR,
157+
body: json!("err"),
158+
provider: "openai".into(),
159+
retryable: false,
160+
};
161+
assert!(provider_err.to_string().contains("openai"));
162+
assert!(provider_err.to_string().contains("500"));
163+
}
164+
}

src/gateway/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod error;
2+
pub mod types;

0 commit comments

Comments
 (0)