BoxAgnts Introduction (7) β OpenAI API and Anthropic API
The 2025 AI model market is in full bloom. But each provider has its own API format, authentication method, and streaming protocol. BoxAgnts' design goal: users switch models by changing just one parameter, with all internal logic remaining unchanged. This article dissects this abstraction across fo

The 2025 AI model market is in full bloom. But each provider has its own API format, authentication method, and streaming protocol. BoxAgnts' design goal: users switch models by changing just one parameter, with all internal logic remaining unchanged. This article dissects this abstraction across four levels: Unified Interface: How the LlmProvider trait defines a "model provider" Three Major API Format Comparisons: Format differences between Anthropic, OpenAI, and Google Gemini Format Conversion: How to translate between three completely different message formats Engineering Practices: Think configuration, error handling, ProviderQuirks, API Key management Everything starts with the interface definition: // boxagnts-api/src/provider.rs #[async_trait] pub trait LlmProvider: Send + Sync { fn id(&self) -> &ProviderId; // Unique identifier fn name(&self) -> &str; // Human-readable name async fn create_message( // Non-streaming request &self, request: ProviderRequest, ) -> Result<ProviderResponse, ProviderError>; async fn create_message_stream( // Streaming request &self, request: ProviderRequest, ) -> Result< Pin<Box<dyn Stream<Item = Result<StreamEvent, ProviderError>> + Send>>, ProviderError, >; async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError>; // Model list async fn check_connectivity(&self) -> Result<ProviderStatus, ProviderError>; // Health check fn capabilities(&self) -> ProviderCapabilities; // Capability declaration } Both input and output use provider-agnostic unified types: pub struct ProviderRequest { pub model: String, pub messages: Vec<Message>, // Unified conversation format pub system_prompt: Option<SystemPrompt>, pub tools: Vec<ToolDefinition>, // Unified tool definitions pub max_tokens: u32, pub temperature: Option<f64>, pub thinking: Option<ThinkingConfig>, // Deep thinking configuration pub provider_options: Value, // Provider-specific parameters } pub struct ProviderResponse { pub id: String, pub content: Vec<ContentBlock>, // Unified content blocks pub stop_reason: StopReason, // Unified stop reason pub usage: UsageInfo, // Token usage pub model: String, } The core value of the normalization layer: whether the underlying is Claude, GPT, or Gemini, upper-layer code only sees ProviderRequest and ProviderResponse. // boxagnts-api/src/registry.rs pub struct ProviderRegistry { providers: HashMap<ProviderId, Arc<dyn LlmProvider>>, default_provider_id: ProviderId, } fn provider_from_key(provider_id: &str, key: String) -> Option<Arc<dyn LlmProvider>> { match provider_id { // Native implementations β each with its own API format "anthropic" => Some(Arc::new(AnthropicProvider::from_config(...))), "openai" => Some(Arc::new(OpenAiProvider::new(key))), "google" => Some(Arc::new(GoogleProvider::new(key))), "github-copilot" => Some(Arc::new(CopilotProvider::new(key))), "cohere" => Some(Arc::new(CohereProvider::new(key))), // OpenAI-compatible providers β share the same conversion logic, only change base_url "deepseek", "groq", "ollama", "mistral", "xai", "perplexity", "openrouter", "siliconflow", "moonshot", "zhipu", "stepfun", "fireworks", "llamacpp", "sambanova", "huggingface", "nvidia", "cerebras", // ... 30+ OpenAI-compatible providers in total _ => None, } } Three implementation strategies: Type Representative Conversion Strategy Count Native Anthropic claude-sonnet-4-5 Near-zero conversion (internal format = Anthropic format) 1 Native OpenAI gpt-4o, o3 ProviderRequest β Chat Completions 1 Native Google gemini-2.5-flash ProviderRequest β generateContent 1 OpenAI Compatible deepseek, groq, ollama, etc. Same logic as OpenAI, only URL changes 30+ Other Native github-copilot, cohere Independent format conversion 3+ Anthropic, OpenAI, Google Gemini β three APIs with vast differences in message format. Understanding these differences is essential to understanding the value of the conversion layer. Feature Anthropic OpenAI Google Gemini Location Top-level "system" field messages[0], role:"system" Top-level "systemInstruction" field Type string or ContentBlock array string only content parts array only // Anthropic β top-level standalone field {"model": "claude-sonnet-4-5", "system": "You are helpful.", "messages": [...]} // OpenAI β embedded in messages array {"model": "gpt-4o", "messages": [{"role":"system","content":"You are helpful."}, ...]} // Google β uses systemInstruction field, structure differs from messages { "systemInstruction": {"parts": [{"text": "You are helpful."}]}, "contents": [{"role": "user", "parts": [{"text": "Hello"}]}] } Feature Anthropic OpenAI Google Field "tools": [{name, description, input_schema}] "tools": [{type:"function", function:{...}}] "tools": [{functionDeclarations: [{name, description, parameters}]}] Wrapping Layers 0 1 1, with different nesting names // Anthropic β native block in content array {"content": [{"type":"tool_use", "id":"toolu_01A", "name":"read", "input": {...}}]} // OpenAI β standalone tool_calls array, arguments is JSON string {"tool_calls": [{"id":"call_abc", "function": {"name":"read", "arguments": "{\"path\":\"...\"}"}}]} // Google β functionCall embedded in parts, args is JSON object {"candidates": [{"content": {"parts": [{"functionCall": {"name":"read", "args": {...}}}]}}]} // Anthropic β tool_result is a block in the user message content array {"role":"user", "content": [{"type":"tool_result", "tool_use_id":"toolu_01A", "content":"..."}]} // OpenAI β requires a separate role: "tool" message {"role":"tool", "tool_call_id":"call_abc", "content":"..."} // Google β functionResponse embedded in user content parts {"role":"user", "parts": [{"functionResponse": {"name":"read", "response": {...}}}]} Anthropic OpenAI Google user user user assistant assistant model Google uses model instead of assistant β this is the most easily overlooked but most error-prone difference. OpenAiProvider is the most complete example of the conversion layer: // boxagnts-api/src/providers/openai.rs impl OpenAiProvider { fn to_openai_messages( messages: &[Message], system_prompt: Option<&SystemPrompt>, ) -> Vec<Value> { let mut result: Vec<Value> = Vec::new(); // Step 1: system prompt β role: "system" message if let Some(sys) = system_prompt { result.push(json!({"role": "system", "content": sys_text})); } for msg in messages { match msg.role { Role::User => { // User messages may mix text and tool_result blocks // tool_result needs to be split into separate role: "tool" messages Self::append_user_messages(&mut result, &msg.content); } Role::Assistant => { let (text, tool_calls) = Self::assistant_content_to_openai(&msg.content); result.push(json!({ "role": "assistant", "content": text, "tool_calls": tool_calls })); } } } result } fn to_openai_tools(tools: &[ToolDefinition]) -> Vec<Value> { tools.iter().map(|td| { json!({ "type": "function", "function": { "name": td.name, "description": td.description, "parameters": td.input_schema } }) }).collect() } } The most complex part is tool_use_id sanitization β Anthropic's tool IDs (e.g., toolu_01Bx...) may contain characters that OpenAI does not accept. GoogleProvider shows how to handle an API format that is different from both Anthropic and OpenAI: // boxagnts-api/src/providers/google.rs // URL pattern completely different from OpenAI's /v1/chat/completions fn generate_url(&self, model: &str) -> String { format!( "{}/v1beta/models/{}:generateContent?key={}", self.base_url, model, self.api_key // API Key in URL query parameters! ) } Key differences from OpenAI: Difference Google Gemini OpenAI API Key Location URL query parameter ?key= HTTP Header Authorization: Bearer Endpoint Format /v1beta/models/{model}:generateContent /v1/chat/completions Streaming Endpoint /v1beta/models/{model}:streamGenerateContent?alt=sse /v1/chat/completions + stream:true Message Roles user / model (not assistant) user / assistant Tool Results functionResponse in parts Separate role: tool message Image Input inlineData base64 image_url or content parts ThinkingConfig is the normalized deep thinking configuration β but different providers handle it completely differently: // Normalized configuration pub struct ThinkingConfig { pub budget_tokens: u32, // Thinking token budget } // When building ProviderRequest, decides whether to pass based on provider capabilities let provider_request = ProviderRequest { // ... thinking: if caps.thinking { effective_thinking_budget .map(|b| ThinkingConfig::enabled(b)) } else { None // This provider doesn't support thinking, don't pass }, }; Provider Thinking Support How It's Passed Anthropic (Claude 3.5+) β "thinking": {"type": "enabled", "budget_tokens": N} Google (Gemini 2.5+) β "thinkingConfig": {"thinkingBudget": N} OpenAI (o1/o3 series) Partial Via reasoning_effort parameter Other OpenAI Compatible Mostly unsupported Not passed At request construction time, ProviderCapabilities declares each provider's capabilities: pub struct ProviderCapabilities { pub thinking: bool, // Whether deep thinking is supported pub prompt_caching: bool, // Whether prompt caching is supported pub image_input: bool, // Whether image input is supported pub native_tool_use: bool, // Whether native tool calling exists pub supports_streaming: bool, // Whether streaming responses are supported // ... } OpenAI-compatible providers' APIs are roughly compatible, but all have subtle differences. ProviderQuirks handles these: pub struct ProviderQuirks { /// Specific error message patterns for context overflow pub overflow_patterns: Vec<String>, /// Local services that don't require API Keys (e.g., Ollama, LM Studio) pub no_api_key_required: bool, /// Whether streaming responses include usage info pub include_usage_in_stream: bool, /// Providers like DeepSeek need the reasoning_content field pub reasoning_field: Option<String>, } For example, DeepSeek's streaming response returns reasoning content with a field name different from OpenAI's β adapted via reasoning_field. Ollama's context overflow error message is "exceeds the available context size", while LM Studio's is "greater than the context length" β adapted via overflow_patterns. Streaming responses are also completely different across the three APIs: Feature Anthropic (SSE) OpenAI (SSE) Google (SSE) Event Granularity High: 6 event types (start/delta/stop Γ 2) Low: each chunk is a complete delta Medium: pushed by chunk, but structure is flat Tool call Increment Fragmented send of input_json_delta Single send of complete arguments string Single send of complete functionCall Termination Signal message_stop event data: [DONE] marker Stream ends naturally Need to Reassemble by index Yes (reassemble by index for multiple tool_use) Yes Yes All three formats are normalized to the same StreamEvent enum: pub enum StreamEvent { MessageStart { id, model, usage }, ContentBlockStart { index, content_block }, TextDelta { text }, ThinkingDelta { thinking }, InputJsonDelta { index, partial_json }, ContentBlockStop { index }, MessageDelta { stop_reason, usage }, MessageStop, } Each provider's error format is also different: // Unified error types pub enum ProviderError { Auth { ... }, // Authentication failure RateLimited { ... }, // Rate limiting ContextOverflow { ... }, // Context exceeds window (matched via ProviderQuirks) InvalidRequest { ... }, // Invalid request parameters ServerError { ... }, // Server error StreamError { ... }, // Stream interruption Other { ... }, // Unknown error } In the query loop, specific errors trigger specific recovery strategies: RateLimited / Overloaded β Switch to fallback_model ContextOverflow β Trigger auto_compact StreamError (stall) β Retry (max 2 times, 45s timeout) Auth β Unrecoverable, return error BoxAgnts defines environment variable name mappings for each provider: // boxagnts-workspace/src/config.rs pub fn api_key_env_vars_for_provider(provider_id: &str) -> &'static [&'static str] { match provider_id { "anthropic" => &["ANTHROPIC_API_KEY"], "openai" => &["OPENAI_API_KEY"], "google" => &["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"], "deepseek" => &["DEEPSEEK_API_KEY"], "mistral" => &["MISTRAL_API_KEY"], "xai" => &["XAI_API_KEY"], "zhipu" => &["ZHIPU_API_KEY"], // ... 40+ provider environment variables } } Three-tier priority: Environment Variables > User Config JSON > No Default. This design supports different scenarios such as multi-tenancy, CI/CD, and local development. BoxAgnts' model abstraction layer solves the essential problem of "one set of code adapting to all APIs": ββββββββββββββββββββββββββββββββββββββββββββββββ β boxagnts-query (Agent reasoning loop) β β Only uses ProviderRequest / ProviderResponse β ββββββββββββββββββββββ¬ββββββββββββββββββββββββββ β ββββββββββββββββββββββΌββββββββββββββββββββββββββ β LlmProvider trait β β + ProviderRegistry (40+ providers) β ββββββββββββ¬βββββββββββ¬βββββββββββ¬ββββββββββββββ€ βAnthropic β OpenAI β Google β OpenAiCompat β βProvider β Provider β Provider β (30+ vendors)β β(Near-zeroβ (Full β (Independentβ (Shares β β conversion)β format β format β OpenAI β β β conversion)β conversion)β conversion β β β β β +Quirks) β ββββββββββββ΄βββββββββββ΄βββββββββββ΄ββββββββββββββ Three key capabilities: User freedom: Switch models by just changing the --model parameter Code unaffected: run_query_loop() has no idea what's underneath Extremely low extension cost: Adding a new OpenAI-compatible provider takes about 3 lines of code This is not a simple "adapter pattern" β it's a production-grade abstraction validated against 40+ real-world APIs. Boxagnts: https://github.com/guyoung/boxagnts Anthropic API: https://docs.anthropic.com/en/api/messages OpenAI API: https://platform.openai.com/docs/api-reference/chat Google Gemini API: https://ai.google.dev/gemini-api/docs
Key Takeaways
- β’The 2025 AI model market is in full bloom
- β’This story was reported by Dev.to, covering developments in the dev space.
- β’AI advancements continue to reshape industries β read the full article on Dev.to for complete coverage.
π Continue reading the full article:
Read Full Article on Dev.to βShare this article



