Skip to content

Commit db7063e

Browse files
cliftonclaude
andcommitted
Improve core ergonomics: runtime client, deps, media safety, model dedup
A focused, non-breaking pass over four first-principles issues surfaced by a design review. (Streaming and tool-calling are intentionally deferred to separate follow-up PRs.) 1. Runtime-selectable client (`AnyClient`) `LLMClient::materialize` is generic, so the trait is not object-safe and `Box<dyn LLMClient>` is impossible. Add `AnyClient` — an enum over the concrete clients that itself implements `LLMClient` — plus a `Provider` enum, `from_env_for`, an env-autodetecting `from_env`, and `From<ConcreteClient>` impls. Gives a single `Clone + Send + Sync` type for picking a provider at runtime. 2. Dependency / feature hygiene + schema-only build - Move `chrono` to `dev-dependencies` (only examples/tests used it) so it leaves the public dependency closure entirely. - Make `base64` optional. - Gate the networked stack (`utils`, `media`, `openai_compatible`, the `reqwest`-backed `HttpError` variant, media `from_bytes`) behind an internal `_client` feature that the provider features enable. - `default-features = false, features = ["derive"]` now compiles with no `tokio`/`reqwest` — a dependency-light, schema-only build that still exposes the derive macro, `SchemaType`, `Instructor`, and `LLMClient`. 3. Media is never silently dropped The default `materialize_with_media` used to ignore media for clients without media support. It now forwards to `materialize` only when no media is given and otherwise returns `RStructorError::Unsupported` (new variant). All four built-in clients already override it, so their behavior is unchanged. 4. De-duplicate the per-provider `Model` enums Add the internal `define_model_enum!` macro that generates each provider's `Model` enum plus `as_str`, `from_string`, `FromStr`, `From<&str>` and `From<String>`. Replaces ~60 lines of identical boilerplate in each of the four backends with a single declaration; behavior and string identifiers are unchanged. Adds tests for the media default and `AnyClient`; verified across the full feature matrix (default, schema-only, and each single provider), `clippy --all-targets`, doctests (0 ignored), and the non-network suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 54ae9b2 commit db7063e

14 files changed

Lines changed: 785 additions & 526 deletions

File tree

Cargo.toml

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,28 @@ tracing-subscriber = { version = "0.3.23", features = [
5959
], optional = true }
6060
tracing-futures = { version = "0.2.5", optional = true }
6161
rstructor_derive = { version = "0.2.11", path = "./rstructor_derive", optional = true }
62-
chrono = "0.4.44" # For date/time validation in examples
63-
base64 = "0.22.1"
62+
base64 = { version = "0.22.1", optional = true }
6463

6564
# Feature flags
6665
[features]
6766
default = ["openai", "anthropic", "grok", "gemini", "derive", "logging"]
68-
openai = ["reqwest", "tokio"]
69-
anthropic = ["reqwest", "tokio"]
70-
grok = ["reqwest", "tokio"]
71-
gemini = ["reqwest", "tokio"]
67+
# Each provider pulls in the shared networking + media stack via `_client`.
68+
openai = ["_client"]
69+
anthropic = ["_client"]
70+
grok = ["_client"]
71+
gemini = ["_client"]
7272
derive = ["rstructor_derive"]
7373
logging = ["tracing-subscriber", "tracing-futures"]
74+
# Internal: the HTTP client + media stack shared by every networked provider.
75+
# Not meant to be enabled directly — enable a provider feature instead. Disabling
76+
# all providers yields a dependency-light, schema-only build (derive + schema, no
77+
# tokio/reqwest) suitable for generating JSON Schema without making API calls.
78+
_client = ["reqwest", "tokio", "base64"]
79+
80+
[dev-dependencies]
81+
# Used only by examples and tests (date/time fields, custom types); not part of
82+
# the public dependency closure.
83+
chrono = { version = "0.4.44", features = ["serde"] }
7484

7585
[workspace]
7686
members = ["rstructor_derive"]

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,25 @@ let client = OpenAIClient::new("key")?
7979
.model("llama-3.1-70b");
8080
```
8181

82+
### Selecting a provider at runtime
83+
84+
`LLMClient::materialize` is generic, so the trait isn't object-safe (`Box<dyn LLMClient>` is impossible). Use `AnyClient` when the provider is decided at runtime (CLI flag, config, env) and you want to store it in a single type:
85+
86+
```rust
87+
use rstructor::{AnyClient, Provider, LLMClient};
88+
89+
// Pick a provider dynamically, reading its key from the environment.
90+
let provider = Provider::Anthropic; // e.g. parsed from a config file
91+
let client = AnyClient::from_env_for(provider)?;
92+
let movie: Movie = client.materialize("Describe Inception").await?;
93+
94+
// Or auto-detect from whichever API key is set:
95+
let client = AnyClient::from_env()?;
96+
97+
// Or wrap a pre-configured client:
98+
let client: AnyClient = OpenAIClient::from_env()?.model("gpt-5.5").into();
99+
```
100+
82101
## Validation
83102

84103
Add custom validation with automatic retry on failure:
@@ -313,10 +332,19 @@ match client.materialize::<Movie>("...").await {
313332
rstructor = { version = "0.2", features = ["openai", "anthropic", "grok", "gemini"] }
314333
```
315334

316-
- `openai`, `anthropic`, `grok`, `gemini` — Provider backends
335+
- `openai`, `anthropic`, `grok`, `gemini` — Provider backends (each pulls in the shared HTTP/`tokio` stack)
317336
- `derive` — Derive macro (default)
318337
- `logging` — Tracing integration
319338

339+
All features are on by default. For a **schema-only build** — generate JSON Schema from your types with no networking, `tokio`, or `reqwest` — disable the providers:
340+
341+
```toml
342+
[dependencies]
343+
rstructor = { version = "0.2", default-features = false, features = ["derive"] }
344+
```
345+
346+
This keeps the derive macro, `SchemaType`, the `Instructor` trait, and the `LLMClient` trait (so you can implement your own backend) without the async/HTTP dependency tree.
347+
320348
## Examples
321349

322350
See `examples/` for complete working examples:

src/backend/anthropic.rs

Lines changed: 44 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ use async_trait::async_trait;
22
use serde::de::DeserializeOwned;
33
use serde::{Deserialize, Serialize};
44
use serde_json::Value;
5-
use std::str::FromStr;
65
use std::time::Duration;
76
use tracing::{debug, error, info, instrument, trace, warn};
87

8+
use crate::backend::model_macro::define_model_enum;
99
use crate::backend::{
1010
AnthropicMessageContent, ChatMessage, GenerateResult, LLMClient, MaterializeInternalOutput,
1111
MaterializeResult, ModelInfo, ThinkingLevel, TokenUsage, ValidationFailureContext,
@@ -16,110 +16,50 @@ use crate::backend::{
1616
use crate::error::{ApiErrorKind, RStructorError, Result};
1717
use crate::model::Instructor;
1818

19-
/// Anthropic models available for completion
20-
///
21-
/// For the latest available models and their identifiers, check the
22-
/// [Anthropic Models Documentation](https://docs.anthropic.com/en/docs/about-claude/models/all-models).
23-
///
24-
/// # Using Custom Models
25-
///
26-
/// You can specify any model name as a string using `Custom` variant or `FromStr`:
27-
///
28-
/// ```rust
29-
/// use rstructor::AnthropicModel;
30-
/// use std::str::FromStr;
31-
///
32-
/// // Using Custom variant
33-
/// let model = AnthropicModel::Custom("claude-custom".to_string());
34-
///
35-
/// // Using FromStr (useful for config files)
36-
/// let model = AnthropicModel::from_str("claude-custom").unwrap();
37-
///
38-
/// // Or use the convenience method
39-
/// let model = AnthropicModel::from_string("claude-custom");
40-
/// ```
41-
#[derive(Debug, Clone, PartialEq, Eq)]
42-
pub enum AnthropicModel {
43-
/// Claude Opus 4.8 (latest most capable generally available model)
44-
ClaudeOpus48,
45-
/// Claude Opus 4.7 (previous most capable model)
46-
ClaudeOpus47,
47-
/// Claude Sonnet 4.6 (latest balanced model)
48-
ClaudeSonnet46,
49-
/// Claude Opus 4.6 (previous most capable model)
50-
ClaudeOpus46,
51-
/// Claude Opus 4.5 (enhanced Opus 4.5)
52-
ClaudeOpus45,
53-
/// Claude Haiku 4.5 (latest fastest model)
54-
ClaudeHaiku45,
55-
/// Claude Sonnet 4.5 (previous balanced model)
56-
ClaudeSonnet45,
57-
/// Claude Opus 4.1 (enhanced reasoning capabilities)
58-
ClaudeOpus41,
59-
/// Claude Opus 4 (high-intelligence model)
60-
ClaudeOpus4,
61-
/// Claude Sonnet 4 (balanced performance model)
62-
ClaudeSonnet4,
63-
/// Custom model name (for new models or Anthropic-compatible endpoints)
64-
Custom(String),
65-
}
66-
67-
impl AnthropicModel {
68-
pub fn as_str(&self) -> &str {
69-
match self {
70-
AnthropicModel::ClaudeOpus48 => "claude-opus-4-8",
71-
AnthropicModel::ClaudeOpus47 => "claude-opus-4-7",
72-
AnthropicModel::ClaudeSonnet46 => "claude-sonnet-4-6",
73-
AnthropicModel::ClaudeOpus46 => "claude-opus-4-6",
74-
AnthropicModel::ClaudeOpus45 => "claude-opus-4-5-20251101",
75-
AnthropicModel::ClaudeHaiku45 => "claude-haiku-4-5-20251001",
76-
AnthropicModel::ClaudeSonnet45 => "claude-sonnet-4-5-20250929",
77-
AnthropicModel::ClaudeOpus41 => "claude-opus-4-1-20250805",
78-
AnthropicModel::ClaudeOpus4 => "claude-opus-4-20250514",
79-
AnthropicModel::ClaudeSonnet4 => "claude-sonnet-4-20250514",
80-
AnthropicModel::Custom(name) => name,
81-
}
82-
}
83-
84-
/// Create a model from a string. This is a convenience method that always succeeds.
19+
define_model_enum! {
20+
/// Anthropic models available for completion
8521
///
86-
/// If the string matches a known model variant, it returns that variant.
87-
/// Otherwise, it returns `Custom(name)`.
88-
pub fn from_string(name: impl Into<String>) -> Self {
89-
let name = name.into();
90-
match name.as_str() {
91-
"claude-opus-4-8" => AnthropicModel::ClaudeOpus48,
92-
"claude-opus-4-7" => AnthropicModel::ClaudeOpus47,
93-
"claude-sonnet-4-6" => AnthropicModel::ClaudeSonnet46,
94-
"claude-opus-4-6" => AnthropicModel::ClaudeOpus46,
95-
"claude-opus-4-5-20251101" => AnthropicModel::ClaudeOpus45,
96-
"claude-haiku-4-5-20251001" => AnthropicModel::ClaudeHaiku45,
97-
"claude-sonnet-4-5-20250929" => AnthropicModel::ClaudeSonnet45,
98-
"claude-opus-4-1-20250805" => AnthropicModel::ClaudeOpus41,
99-
"claude-opus-4-20250514" => AnthropicModel::ClaudeOpus4,
100-
"claude-sonnet-4-20250514" => AnthropicModel::ClaudeSonnet4,
101-
_ => AnthropicModel::Custom(name),
102-
}
103-
}
104-
}
105-
106-
impl FromStr for AnthropicModel {
107-
type Err = std::convert::Infallible;
108-
109-
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
110-
Ok(AnthropicModel::from_string(s))
111-
}
112-
}
113-
114-
impl From<&str> for AnthropicModel {
115-
fn from(s: &str) -> Self {
116-
AnthropicModel::from_string(s)
117-
}
118-
}
119-
120-
impl From<String> for AnthropicModel {
121-
fn from(s: String) -> Self {
122-
AnthropicModel::from_string(s)
22+
/// For the latest available models and their identifiers, check the
23+
/// [Anthropic Models Documentation](https://docs.anthropic.com/en/docs/about-claude/models/all-models).
24+
///
25+
/// # Using Custom Models
26+
///
27+
/// You can specify any model name as a string using `Custom` variant or `FromStr`:
28+
///
29+
/// ```rust
30+
/// use rstructor::AnthropicModel;
31+
/// use std::str::FromStr;
32+
///
33+
/// // Using Custom variant
34+
/// let model = AnthropicModel::Custom("claude-custom".to_string());
35+
///
36+
/// // Using FromStr (useful for config files)
37+
/// let model = AnthropicModel::from_str("claude-custom").unwrap();
38+
///
39+
/// // Or use the convenience method
40+
/// let model = AnthropicModel::from_string("claude-custom");
41+
/// ```
42+
pub enum AnthropicModel {
43+
/// Claude Opus 4.8 (latest most capable generally available model)
44+
ClaudeOpus48 => "claude-opus-4-8",
45+
/// Claude Opus 4.7 (previous most capable model)
46+
ClaudeOpus47 => "claude-opus-4-7",
47+
/// Claude Sonnet 4.6 (latest balanced model)
48+
ClaudeSonnet46 => "claude-sonnet-4-6",
49+
/// Claude Opus 4.6 (previous most capable model)
50+
ClaudeOpus46 => "claude-opus-4-6",
51+
/// Claude Opus 4.5 (enhanced Opus 4.5)
52+
ClaudeOpus45 => "claude-opus-4-5-20251101",
53+
/// Claude Haiku 4.5 (latest fastest model)
54+
ClaudeHaiku45 => "claude-haiku-4-5-20251001",
55+
/// Claude Sonnet 4.5 (previous balanced model)
56+
ClaudeSonnet45 => "claude-sonnet-4-5-20250929",
57+
/// Claude Opus 4.1 (enhanced reasoning capabilities)
58+
ClaudeOpus41 => "claude-opus-4-1-20250805",
59+
/// Claude Opus 4 (high-intelligence model)
60+
ClaudeOpus4 => "claude-opus-4-20250514",
61+
/// Claude Sonnet 4 (balanced performance model)
62+
ClaudeSonnet4 => "claude-sonnet-4-20250514",
12363
}
12464
}
12565

0 commit comments

Comments
 (0)