diff --git a/.env.local.example b/.env.local.example index 9976c0c..a4bc539 100644 --- a/.env.local.example +++ b/.env.local.example @@ -5,6 +5,7 @@ # --- LLM provider (required by the example) --- OPENAI_API_KEY= +ANTHROPIC_API_KEY= # --- Langfuse (required) --- # Project keys from your Langfuse instance -> Settings -> API keys diff --git a/.gitmodules b/.gitmodules index b1ac7d7..77f1d55 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "third_party/zlib"] path = third_party/zlib url = https://github.com/madler/zlib.git -[submodule "third_party/brotli"] - path = third_party/brotli - url = https://github.com/google/brotli.git [submodule "third_party/googletest"] path = third_party/googletest url = https://github.com/google/googletest.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 127f53d..1849e29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,7 +37,7 @@ endfunction() # Common compile definitions for HTTP support set(HTTPLIB_COMPILE_DEFS CPPHTTPLIB_OPENSSL_SUPPORT=1 - CPPHTTPLIB_BROTLI_SUPPORT=1 + CPPHTTPLIB_ZLIB_SUPPORT=1 CPPHTTPLIB_THREAD_POOL_COUNT=8 ) @@ -48,6 +48,13 @@ add_library(ai-sdk-cpp-anthropic) add_library(ai-sdk-cpp-langfuse) add_library(ai-sdk-cpp INTERFACE) +# Keep installed target names consistent with the build-tree aliases. +set_target_properties(ai-sdk-cpp-core PROPERTIES EXPORT_NAME core) +set_target_properties(ai-sdk-cpp-openai PROPERTIES EXPORT_NAME openai) +set_target_properties(ai-sdk-cpp-anthropic PROPERTIES EXPORT_NAME anthropic) +set_target_properties(ai-sdk-cpp-langfuse PROPERTIES EXPORT_NAME langfuse) +set_target_properties(ai-sdk-cpp PROPERTIES EXPORT_NAME sdk) + # Set library aliases add_library(ai::sdk ALIAS ai-sdk-cpp) add_library(ai::core ALIAS ai-sdk-cpp-core) @@ -80,6 +87,7 @@ target_sources(ai-sdk-cpp-core # HTTP and base provider infrastructure src/http/http_request_handler.cpp src/providers/base_provider_client.cpp + src/providers/http_sse_stream.cpp # Tool calling implementations src/tools/tool_executor.cpp @@ -121,6 +129,10 @@ target_link_libraries(ai-sdk-cpp-core $ $ $ + $ + $ + $ + $ ) # Provider components link to core and share common dependencies @@ -132,6 +144,10 @@ foreach(provider openai anthropic) PRIVATE $ $ + $ + $ + $ + $ ) # Set component availability and HTTP definitions @@ -155,6 +171,10 @@ target_link_libraries(ai-sdk-cpp-langfuse $ $ $ + $ + $ + $ + $ ) target_compile_definitions(ai-sdk-cpp-langfuse PUBLIC @@ -268,6 +288,7 @@ configure_package_config_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ai-sdk-cpp-config.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/ai-sdk-cpp-config.cmake" INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ai-sdk-cpp + PATH_VARS CMAKE_INSTALL_INCLUDEDIR ) write_basic_package_version_file( @@ -290,4 +311,4 @@ message(STATUS " C++ standard: ${CMAKE_CXX_STANDARD}") message(STATUS " Build examples: ${BUILD_EXAMPLES}") message(STATUS " Build tests: ${BUILD_TESTS}") message(STATUS " Install prefix: ${CMAKE_INSTALL_PREFIX}") -message(STATUS "") \ No newline at end of file +message(STATUS "") diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 610713d..4367a20 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -325,18 +325,18 @@ With **debug** level enabled: ``` [2024-01-01 12:00:00.123] [debug] Initializing OpenAI client with base_url: https://api.openai.com [2024-01-01 12:00:00.124] [debug] OpenAI client configured - host: api.openai.com, use_ssl: true -[2024-01-01 12:00:00.125] [debug] Starting text generation - model: gpt-4o, prompt length: 42 -[2024-01-01 12:00:00.126] [debug] Request JSON built: {"model":"gpt-4o","messages":[...]} +[2024-01-01 12:00:00.125] [debug] Starting text generation - model: gpt-5.6, prompt length: 42 +[2024-01-01 12:00:00.126] [debug] Request JSON built: {"model":"gpt-5.6","messages":[...]} [2024-01-01 12:00:00.127] [debug] Creating SSL client for host: api.openai.com [2024-01-01 12:00:01.234] [debug] Received response - status: 200, body length: 1234 -[2024-01-01 12:00:01.235] [info] Text generation successful - model: gpt-4o, response_id: chatcmpl-abc123 +[2024-01-01 12:00:01.235] [info] Text generation successful - model: gpt-5.6, response_id: chatcmpl-abc123 ``` With **info** level enabled: ``` -[2024-01-01 12:00:01.235] [info] Text generation successful - model: gpt-4o, response_id: chatcmpl-abc123 -[2024-01-01 12:00:02.456] [info] Text streaming started - model: gpt-4o-mini +[2024-01-01 12:00:01.235] [info] Text generation successful - model: gpt-5.6, response_id: chatcmpl-abc123 +[2024-01-01 12:00:02.456] [info] Text streaming started - model: gpt-5.6-luna [2024-01-01 12:00:03.789] [info] Stream completed - tokens used: 150 prompt, 350 completion, 500 total ``` diff --git a/README.md b/README.md index a9eb346..daf536f 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,17 @@ The AI SDK CPP Core module provides a unified API to interact with model provide ```cpp #include -#include +#include #include int main() { // Ensure OPENAI_API_KEY environment variable is set auto client = ai::openai::create_client(); - auto result = client.generate_text({ - .model = ai::openai::models::kGpt54, // this can also be a string like "gpt-5.4" - .system = "You are a friendly assistant!", - .prompt = "Why is the sky blue?" - }); + ai::GenerateOptions options(ai::openai::models::kGpt56, + "Why is the sky blue?"); + options.system = "You are a friendly assistant!"; + auto result = client.generate_text(options); if (result) { std::cout << result->text << std::endl; @@ -49,17 +48,16 @@ int main() { ```cpp #include -#include +#include #include int main() { // Ensure ANTHROPIC_API_KEY environment variable is set auto client = ai::anthropic::create_client(); - auto result = client.generate_text({ - .model = ai::anthropic::models::kClaudeSonnet46, - .system = "You are a helpful assistant.", - .prompt = "Explain quantum computing in simple terms." - }); + ai::GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, + "Explain quantum computing in simple terms."); + options.system = "You are a helpful assistant."; + auto result = client.generate_text(options); if (result) { std::cout << result->text << std::endl; @@ -73,21 +71,20 @@ int main() { ```cpp #include -#include +#include #include int main() { auto client = ai::openai::create_client(); - auto stream = client.stream_text({ - .model = ai::openai::models::kGpt54, // this can also be a string like "gpt-5.4" - .system = "You are a helpful assistant.", - .prompt = "Write a short story about a robot." - }); + ai::GenerateOptions generate_options(ai::openai::models::kGpt56, + "Write a short story about a robot."); + generate_options.system = "You are a helpful assistant."; + auto stream = client.stream_text(ai::StreamOptions(generate_options)); - for (const auto& chunk : stream) { - if (chunk.text) { - std::cout << chunk.text.value() << std::flush; + for (const auto& event : stream) { + if (event.is_text_delta()) { + std::cout << event.text_delta << std::flush; } } @@ -99,23 +96,21 @@ int main() { ```cpp #include -#include +#include #include int main() { auto client = ai::openai::create_client(); ai::Messages messages = { - {"system", "You are a helpful math tutor."}, - {"user", "What is 2 + 2?"}, - {"assistant", "2 + 2 equals 4."}, - {"user", "Now what is 4 + 4?"} + ai::Message::system("You are a helpful math tutor."), + ai::Message::user("What is 2 + 2?"), + ai::Message::assistant("2 + 2 equals 4."), + ai::Message::user("Now what is 4 + 4?") }; - auto result = client.generate_text({ - .model = ai::openai::models::kGpt54, // this can also be a string like "gpt-5.4" - .messages = messages - }); + auto result = client.generate_text( + ai::GenerateOptions(ai::openai::models::kGpt56, messages)); if (result) { std::cout << result->text << std::endl; @@ -131,7 +126,7 @@ The AI SDK CPP supports function calling, allowing models to interact with exter ```cpp #include -#include +#include #include #include @@ -160,12 +155,11 @@ int main() { )} }; - auto result = client.generate_text({ - .model = ai::openai::models::kGpt54, - .prompt = "What's the weather like in San Francisco?", - .tools = tools, - .max_steps = 3 // Enable multi-step tool calling - }); + ai::GenerateOptions options(ai::openai::models::kGpt56, + "What's the weather like in San Francisco?"); + options.tools = tools; + options.max_steps = 3; + auto result = client.generate_text(options); if (result) { std::cout << result->text << std::endl; @@ -216,11 +210,10 @@ int main() { }; // Multiple async tools will execute in parallel - auto result = client.generate_text({ - .model = ai::openai::models::kGpt54, - .prompt = "Fetch data from the user and product APIs", - .tools = tools - }); + ai::GenerateOptions options(ai::openai::models::kGpt56, + "Fetch data from the user and product APIs"); + options.tools = tools; + auto result = client.generate_text(options); return 0; } @@ -251,22 +244,23 @@ int main() { // The client will automatically retry on transient failures: // - Network errors // - HTTP 408, 409, 429 (rate limits), and 5xx errors - auto result = client.generate_text({ - .model = ai::openai::models::kGpt54, - .prompt = "Hello, world!" - }); + auto result = client.generate_text( + ai::GenerateOptions(ai::openai::models::kGpt56, "Hello, world!")); return 0; } ``` +The same retry-config overload is available from +`ai::anthropic::create_client` for Anthropic requests. + #### Using OpenAI-Compatible APIs (OpenRouter, etc.) The OpenAI client can be used with any OpenAI-compatible API by specifying a custom base URL. This allows you to use alternative providers like OpenRouter, which offers access to multiple models through a unified API. ```cpp #include -#include +#include #include #include @@ -285,11 +279,10 @@ int main() { ); // Use any model available on OpenRouter - auto result = client.generate_text({ - .model = "anthropic/claude-sonnet-4-6", // or "meta-llama/llama-3.1-8b-instruct", etc. - .system = "You are a helpful assistant.", - .prompt = "What are the benefits of using OpenRouter?" - }); + ai::GenerateOptions options("anthropic/claude-sonnet-5", + "What are the benefits of using OpenRouter?"); + options.system = "You are a helpful assistant."; + auto result = client.generate_text(options); if (result) { std::cout << result->text << std::endl; @@ -314,6 +307,7 @@ See the [OpenRouter example](examples/openrouter_example.cpp) for a complete dem - โœ… **Streaming**: Real-time streaming of generated content - โœ… **Multi-turn Conversations**: Support for conversation history - โœ… **Error Handling**: Comprehensive error handling with optional types +- โœ… **Embeddings**: OpenAI text embedding support ### Recently Added @@ -324,7 +318,6 @@ See the [OpenRouter example](examples/openrouter_example.cpp) for a complete dem ### Coming Soon - ๐Ÿšง **Additional Providers**: Google, Cohere, and other providers -- ๐Ÿšง **Embeddings**: Text embedding support - ๐Ÿšง **Image Generation**: Support for image generation models ## Examples diff --git a/cmake/ai-sdk-cpp-config.cmake.in b/cmake/ai-sdk-cpp-config.cmake.in index 0654d05..b6801db 100644 --- a/cmake/ai-sdk-cpp-config.cmake.in +++ b/cmake/ai-sdk-cpp-config.cmake.in @@ -2,6 +2,11 @@ # AI SDK C++ Package Configuration +include(CMakeFindDependencyMacro) +find_dependency(OpenSSL REQUIRED) +find_dependency(Threads REQUIRED) +find_dependency(ZLIB REQUIRED) + set(AI_SDK_VERSION "@PROJECT_VERSION@") # Find PUBLIC dependencies only @@ -11,14 +16,16 @@ find_dependency(nlohmann_json CONFIG REQUIRED) include("${CMAKE_CURRENT_LIST_DIR}/ai-sdk-cpp-targets.cmake") # Available components -set(AI_SDK_AVAILABLE_COMPONENTS core openai anthropic) +set(AI_SDK_AVAILABLE_COMPONENTS core openai anthropic langfuse) # Check requested components set(AI_SDK_REQUESTED_COMPONENTS ${ai-sdk-cpp_FIND_COMPONENTS}) # If no components requested, use all available +set(AI_SDK_USE_UMBRELLA_TARGET FALSE) if(NOT AI_SDK_REQUESTED_COMPONENTS) set(AI_SDK_REQUESTED_COMPONENTS ${AI_SDK_AVAILABLE_COMPONENTS}) + set(AI_SDK_USE_UMBRELLA_TARGET TRUE) endif() # Check that all requested components are available @@ -54,10 +61,8 @@ foreach(component ${AI_SDK_REQUESTED_COMPONENTS}) list(APPEND AI_SDK_LIBRARIES ai::${component}) endforeach() -# Always add main library if no specific components requested -if("core" IN_LIST AI_SDK_REQUESTED_COMPONENTS AND - "openai" IN_LIST AI_SDK_REQUESTED_COMPONENTS AND - "anthropic" IN_LIST AI_SDK_REQUESTED_COMPONENTS) +# Add the umbrella library only when no specific components were requested. +if(AI_SDK_USE_UMBRELLA_TARGET) list(APPEND AI_SDK_LIBRARIES ai::sdk) endif() @@ -66,4 +71,4 @@ if(NOT TARGET ai::core) message(FATAL_ERROR "ai::core target not found. Make sure ai-sdk-cpp was properly installed.") endif() -message(STATUS "Found AI SDK C++: ${CMAKE_CURRENT_LIST_DIR} (found version \"${AI_SDK_VERSION}\")") \ No newline at end of file +message(STATUS "Found AI SDK C++: ${CMAKE_CURRENT_LIST_DIR} (found version \"${AI_SDK_VERSION}\")") diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 9aa0784..13f6147 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -62,18 +62,24 @@ add_subdirectory(components/anthropic) add_subdirectory(components/all) # Message to show how to run examples +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(EXAMPLE_DISPLAY_SUFFIX "_debug") +else() + set(EXAMPLE_DISPLAY_SUFFIX "") +endif() + message(STATUS "Examples will be built in: ${CMAKE_BINARY_DIR}/examples/") message(STATUS "To run examples:") message(STATUS " Set environment variables: OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY") -message(STATUS " Run: ./examples/basic_chat") -message(STATUS " ./examples/streaming_chat") -message(STATUS " ./examples/multi_provider") -message(STATUS " ./examples/error_handling") -message(STATUS " ./examples/test_openai") -message(STATUS " ./examples/test_anthropic") -message(STATUS " ./examples/openrouter_example") +message(STATUS " Run: ./examples/basic_chat${EXAMPLE_DISPLAY_SUFFIX}") +message(STATUS " ./examples/streaming_chat${EXAMPLE_DISPLAY_SUFFIX}") +message(STATUS " ./examples/multi_provider${EXAMPLE_DISPLAY_SUFFIX}") +message(STATUS " ./examples/error_handling${EXAMPLE_DISPLAY_SUFFIX}") +message(STATUS " ./examples/test_openai${EXAMPLE_DISPLAY_SUFFIX}") +message(STATUS " ./examples/test_anthropic${EXAMPLE_DISPLAY_SUFFIX}") +message(STATUS " ./examples/openrouter_example${EXAMPLE_DISPLAY_SUFFIX}") message(STATUS "") message(STATUS "Component-specific examples:") message(STATUS " ./examples/components/openai/openai_component_demo (OpenAI + Core only)") message(STATUS " ./examples/components/anthropic/anthropic_component_demo (Anthropic + Core only)") -message(STATUS " ./examples/components/all/all_components_demo (All components)") \ No newline at end of file +message(STATUS " ./examples/components/all/all_components_demo (All components)") diff --git a/examples/basic_chat.cpp b/examples/basic_chat.cpp index d228783..d179b31 100644 --- a/examples/basic_chat.cpp +++ b/examples/basic_chat.cpp @@ -29,7 +29,7 @@ int main() { auto client1 = ai::openai::create_client(); ai::GenerateOptions options1; - options1.model = ai::openai::models::kGpt54; + options1.model = ai::openai::models::kGpt56; options1.prompt = "What is the capital of France? Please provide a brief answer."; @@ -50,7 +50,7 @@ int main() { std::cout << "Question: Explain what a prime number is.\n\n"; ai::GenerateOptions options2; - options2.model = ai::openai::models::kGpt54; + options2.model = ai::openai::models::kGpt56; options2.system = "You are a helpful math tutor who explains concepts clearly."; options2.prompt = @@ -80,7 +80,7 @@ int main() { "Which one should I use for frequent insertions in the middle?")}; ai::GenerateOptions options3; - options3.model = ai::openai::models::kGpt54; + options3.model = ai::openai::models::kGpt56; options3.messages = conversation; auto result3 = client1.generate_text(options3); @@ -98,7 +98,7 @@ int main() { auto client4 = ai::anthropic::create_client(); ai::GenerateOptions options4; - options4.model = ai::anthropic::models::kClaudeSonnet46; + options4.model = ai::anthropic::models::kClaudeSonnet5; options4.prompt = "Write a haiku about programming. Just the haiku, nothing else."; @@ -115,7 +115,7 @@ int main() { std::cout << "5. Using GenerateOptions for fine control:\n"; ai::GenerateOptions options; - options.model = ai::openai::models::kGpt54; + options.model = ai::openai::models::kGpt56; options.prompt = "List 3 benefits of using C++ for systems programming."; options.max_tokens = 150; options.temperature = 0.7; diff --git a/examples/components/all/main.cpp b/examples/components/all/main.cpp index 25a21c2..67dd8fe 100644 --- a/examples/components/all/main.cpp +++ b/examples/components/all/main.cpp @@ -26,7 +26,7 @@ int main() { // Test core functionality std::cout << "Testing core functionality...\n"; ai::GenerateOptions options; - options.model = "gpt-5.4"; + options.model = ai::openai::models::kGpt56; options.prompt = "Hello world"; std::cout << "โœ“ Core types work fine\n\n"; @@ -36,8 +36,8 @@ int main() { try { auto openai_client = ai::openai::create_client(); std::cout << "โœ“ OpenAI client created successfully\n"; - std::cout << "โœ“ Available models: " << ai::openai::models::kGpt54 << ", " - << ai::openai::models::kGpt54Mini << "\n"; + std::cout << "โœ“ Available models: " << ai::openai::models::kGpt56 << ", " + << ai::openai::models::kGpt56Terra << "\n"; } catch (const std::exception& e) { std::cout << "โœ— OpenAI client failed: " << e.what() << "\n"; } @@ -51,9 +51,8 @@ int main() { try { auto anthropic_client = ai::anthropic::create_client(); std::cout << "โœ“ Anthropic client created successfully\n"; - std::cout << "โœ“ Available models: " - << ai::anthropic::models::kClaudeSonnet46 << ", " - << ai::anthropic::models::kClaudeHaiku45 << "\n"; + std::cout << "โœ“ Available models: " << ai::anthropic::models::kClaudeSonnet5 + << ", " << ai::anthropic::models::kClaudeHaiku45 << "\n"; } catch (const std::exception& e) { std::cout << "โœ— Anthropic client failed: " << e.what() << "\n"; } @@ -67,4 +66,4 @@ int main() { << "Both OpenAI and Anthropic should be available when linking ai::sdk\n"; return 0; -} \ No newline at end of file +} diff --git a/examples/components/anthropic/main.cpp b/examples/components/anthropic/main.cpp index e329c37..e640533 100644 --- a/examples/components/anthropic/main.cpp +++ b/examples/components/anthropic/main.cpp @@ -26,7 +26,7 @@ int main() { // Test core functionality std::cout << "Testing core functionality...\n"; ai::GenerateOptions options; - options.model = "claude-sonnet-4-6"; + options.model = ai::anthropic::models::kClaudeSonnet5; options.prompt = "Hello world"; std::cout << "โœ“ Core types work fine\n\n"; @@ -36,9 +36,8 @@ int main() { try { auto client = ai::anthropic::create_client(); std::cout << "โœ“ Anthropic client created successfully\n"; - std::cout << "โœ“ Available models: " - << ai::anthropic::models::kClaudeSonnet46 << ", " - << ai::anthropic::models::kClaudeHaiku45 << "\n"; + std::cout << "โœ“ Available models: " << ai::anthropic::models::kClaudeSonnet5 + << ", " << ai::anthropic::models::kClaudeHaiku45 << "\n"; } catch (const std::exception& e) { std::cout << "โœ— Anthropic client failed: " << e.what() << "\n"; } @@ -62,4 +61,4 @@ int main() { #endif return 0; -} \ No newline at end of file +} diff --git a/examples/components/openai/main.cpp b/examples/components/openai/main.cpp index a0558a1..37dab1f 100644 --- a/examples/components/openai/main.cpp +++ b/examples/components/openai/main.cpp @@ -26,7 +26,7 @@ int main() { // Test core functionality std::cout << "Testing core functionality...\n"; ai::GenerateOptions options; - options.model = "gpt-5.4"; + options.model = ai::openai::models::kGpt56; options.prompt = "Hello world"; std::cout << "โœ“ Core types work fine\n\n"; @@ -36,8 +36,8 @@ int main() { try { auto client = ai::openai::create_client(); std::cout << "โœ“ OpenAI client created successfully\n"; - std::cout << "โœ“ Available models: " << ai::openai::models::kGpt54 << ", " - << ai::openai::models::kGpt54Mini << "\n"; + std::cout << "โœ“ Available models: " << ai::openai::models::kGpt56 << ", " + << ai::openai::models::kGpt56Terra << "\n"; } catch (const std::exception& e) { std::cout << "โœ— OpenAI client failed: " << e.what() << "\n"; } @@ -61,4 +61,4 @@ int main() { #endif return 0; -} \ No newline at end of file +} diff --git a/examples/error_handling.cpp b/examples/error_handling.cpp index f954366..10703f3 100644 --- a/examples/error_handling.cpp +++ b/examples/error_handling.cpp @@ -37,7 +37,7 @@ void demonstrate_api_errors() { // Test with empty prompt std::cout << "Testing with empty prompt:\n"; ai::GenerateOptions options2; - options2.model = ai::openai::models::kGpt54; + options2.model = ai::openai::models::kGpt56; options2.prompt = ""; auto result2 = client.generate_text(options2); @@ -81,7 +81,7 @@ void demonstrate_validation() { // Test valid options ai::GenerateOptions valid_options; - valid_options.model = ai::openai::models::kGpt54; + valid_options.model = ai::openai::models::kGpt56; valid_options.prompt = "Hello"; if (valid_options.is_valid()) { @@ -181,8 +181,8 @@ void demonstrate_recovery_patterns() { std::vector fallback_models = { "primary-model-v3", // This will fail - ai::openai::models::kGpt54, // This should work (if API key is available) - ai::openai::models::kGpt54Mini // Faster fallback + ai::openai::models::kGpt56, // This should work (if API key is available) + ai::openai::models::kGpt56Terra // Faster fallback }; std::string prompt = "What is machine learning?"; @@ -289,14 +289,14 @@ void demonstrate_logging() { std::cout << "\n2. Include context information:\n"; std::cout << " ERROR: Text generation failed\n"; - std::cout << " Context: model=gpt-4o, prompt_length=156, user_id=12345\n"; + std::cout << " Context: model=gpt-5.6, prompt_length=156, user_id=12345\n"; std::cout << " Details: " << result.error_message() << "\n"; std::cout << "\n3. Use structured logging:\n"; std::cout << " " "{\"level\":\"error\",\"component\":\"ai-sdk\",\"operation\":" "\"generate_text\","; - std::cout << "\"model\":\"gpt-4o\",\"error\":\"invalid_model\"}\n"; + std::cout << "\"model\":\"gpt-5.6\",\"error\":\"invalid_model\"}\n"; std::cout << "\n4. Log performance metrics:\n"; std::cout << " INFO: Text generation completed in 1250ms, 45 tokens used\n"; @@ -333,4 +333,4 @@ int main() { << " 8. Use the SDK's built-in error types for specific handling\n"; return 0; -} \ No newline at end of file +} diff --git a/examples/langfuse_tracing.cpp b/examples/langfuse_tracing.cpp index f772614..844b2e7 100644 --- a/examples/langfuse_tracing.cpp +++ b/examples/langfuse_tracing.cpp @@ -91,7 +91,7 @@ int main() { ai::create_object_schema({{"location", "string"}}), get_weather); ai::GenerateOptions options; - options.model = ai::openai::models::kGpt4oMini; + options.model = ai::openai::models::kGpt56Luna; options.system = "You are a concise assistant. Use the available tools when helpful."; options.prompt = "Look up alice and tell me the weather where she lives."; diff --git a/examples/multi_provider.cpp b/examples/multi_provider.cpp index a28c24a..c49257c 100644 --- a/examples/multi_provider.cpp +++ b/examples/multi_provider.cpp @@ -88,13 +88,13 @@ int main() { // Test OpenAI models results1.push_back( - test_provider("OpenAI", ai::openai::models::kGpt54, simple_question)); - results1.push_back( - test_provider("OpenAI", ai::openai::models::kGpt54Mini, simple_question)); + test_provider("OpenAI", ai::openai::models::kGpt56, simple_question)); + results1.push_back(test_provider("OpenAI", ai::openai::models::kGpt56Terra, + simple_question)); // Test Anthropic models results1.push_back(test_provider( - "Anthropic", ai::anthropic::models::kClaudeSonnet46, simple_question)); + "Anthropic", ai::anthropic::models::kClaudeSonnet5, simple_question)); results1.push_back(test_provider( "Anthropic", ai::anthropic::models::kClaudeHaiku45, simple_question)); @@ -116,9 +116,9 @@ int main() { // Test with different providers for creativity results2.push_back( - test_provider("OpenAI", ai::openai::models::kGpt54, creative_prompt)); + test_provider("OpenAI", ai::openai::models::kGpt56, creative_prompt)); results2.push_back(test_provider( - "Anthropic", ai::anthropic::models::kClaudeSonnet46, creative_prompt)); + "Anthropic", ai::anthropic::models::kClaudeSonnet5, creative_prompt)); for (const auto& result : results2) { print_result(result); @@ -135,9 +135,9 @@ int main() { std::vector results3; results3.push_back( - test_provider("OpenAI", ai::openai::models::kGpt54, technical_prompt)); + test_provider("OpenAI", ai::openai::models::kGpt56, technical_prompt)); results3.push_back(test_provider( - "Anthropic", ai::anthropic::models::kClaudeSonnet46, technical_prompt)); + "Anthropic", ai::anthropic::models::kClaudeSonnet5, technical_prompt)); for (const auto& result : results3) { print_result(result); diff --git a/examples/openrouter_example.cpp b/examples/openrouter_example.cpp index 3d0bf85..291e3d7 100644 --- a/examples/openrouter_example.cpp +++ b/examples/openrouter_example.cpp @@ -32,11 +32,11 @@ int main() { std::cout << "Testing text generation with OpenRouter...\n\n"; // Using a model that's available on OpenRouter - // Common models: "openai/gpt-5.4", "anthropic/claude-sonnet-4-6", + // Common models: "openai/gpt-5.6-sol", "anthropic/claude-sonnet-5", // "meta-llama/llama-3.1-8b-instruct" See https://openrouter.ai/models for // available models ai::GenerateOptions options( - "anthropic/claude-sonnet-4-6", "You are a helpful assistant.", + "anthropic/claude-sonnet-5", "You are a helpful assistant.", "What are the benefits of using OpenRouter for AI applications? Give a " "brief answer."); @@ -55,7 +55,7 @@ int main() { // Test streaming with OpenRouter std::cout << "\n\nTesting streaming with OpenRouter...\n"; - ai::GenerateOptions stream_opts("anthropic/claude-sonnet-4-6", + ai::GenerateOptions stream_opts("anthropic/claude-sonnet-5", "You are a creative writer.", "Write a haiku about API compatibility."); ai::StreamOptions stream_options(stream_opts); diff --git a/examples/retry_config_example.cpp b/examples/retry_config_example.cpp index 03729cb..3536a0b 100644 --- a/examples/retry_config_example.cpp +++ b/examples/retry_config_example.cpp @@ -33,7 +33,7 @@ int main() { auto default_client = ai::openai::create_client(); ai::GenerateOptions options; - options.model = ai::openai::models::kGpt54Mini; + options.model = ai::openai::models::kGpt56Terra; options.prompt = "Say 'Hello with default retry config!'"; auto result1 = default_client.generate_text(options); @@ -56,7 +56,7 @@ int main() { // Get API key from environment const char* api_key = std::getenv("OPENAI_API_KEY"); - if (!api_key) { + if (!api_key || *api_key == '\0') { std::cerr << "Error: OPENAI_API_KEY environment variable not set\n"; return 1; } @@ -138,7 +138,7 @@ int main() { options.prompt = "Generate a list of 5 creative project names for a batch processing " "system."; - options.model = ai::openai::models::kGpt54; + options.model = ai::openai::models::kGpt56; std::cout << "Processing batch request (this might take a while if retries " "occur)...\n"; diff --git a/examples/streaming_chat.cpp b/examples/streaming_chat.cpp index 797e934..a957f4a 100644 --- a/examples/streaming_chat.cpp +++ b/examples/streaming_chat.cpp @@ -30,7 +30,7 @@ int main() { auto client = ai::openai::create_client(); ai::GenerateOptions gen_options1; - gen_options1.model = ai::openai::models::kGpt54; + gen_options1.model = ai::openai::models::kGpt56; gen_options1.prompt = "Write a short story about a robot learning " "to paint. Keep it under 200 words."; @@ -77,7 +77,7 @@ int main() { }; ai::GenerateOptions gen_options2; - gen_options2.model = ai::openai::models::kGpt54Mini; + gen_options2.model = ai::openai::models::kGpt56Terra; gen_options2.prompt = "Explain quantum computing in simple terms that a " "high school student could understand."; @@ -102,7 +102,7 @@ int main() { std::cout << "Response: "; ai::GenerateOptions gen_options3; - gen_options3.model = ai::openai::models::kGpt54; + gen_options3.model = ai::openai::models::kGpt56; gen_options3.messages = conversation; ai::StreamOptions options3(std::move(gen_options3)); auto stream3 = client.stream_text(options3); @@ -139,7 +139,7 @@ int main() { std::cout << "Prompt: Write 3 unusual ice cream flavors.\n\n"; ai::GenerateOptions gen_options4; - gen_options4.model = ai::openai::models::kGpt54; + gen_options4.model = ai::openai::models::kGpt56; gen_options4.prompt = "Invent 3 unusual but delicious ice cream flavors with creative names."; gen_options4.temperature = 1.2; // High creativity diff --git a/examples/test_openai.cpp b/examples/test_openai.cpp index 54db8f5..1332647 100644 --- a/examples/test_openai.cpp +++ b/examples/test_openai.cpp @@ -16,7 +16,7 @@ int main() { // Test simple generation std::cout << "Testing OpenAI text generation...\n\n"; - ai::GenerateOptions options(ai::openai::models::kGpt54Mini, + ai::GenerateOptions options(ai::openai::models::kGpt56Terra, "You are a friendly assistant!", "Why is the sky blue? Give a short answer."); @@ -35,7 +35,7 @@ int main() { std::cout << "\nTesting streaming...\n"; ai::GenerateOptions stream_opts( - ai::openai::models::kGpt54Mini, + ai::openai::models::kGpt56Terra, "Count from 1 to 5 slowly and along with each number say 'tick'"); ai::StreamOptions stream_options(stream_opts); diff --git a/examples/test_tool_integration.cpp b/examples/test_tool_integration.cpp index c1fb3d1..7fe4c05 100644 --- a/examples/test_tool_integration.cpp +++ b/examples/test_tool_integration.cpp @@ -42,7 +42,7 @@ void test_openai_tools() { {{"input", "string"}}, simple_test_tool)}}; ai::GenerateOptions options; - options.model = ai::openai::models::kGpt54; + options.model = ai::openai::models::kGpt56; options.prompt = "Please use the test_tool with input 'hello world'"; options.tools = tools; options.tool_choice = @@ -86,7 +86,7 @@ void test_anthropic_tools() { {{"input", "string"}}, simple_test_tool)}}; ai::GenerateOptions options; - options.model = ai::anthropic::models::kClaudeSonnet46; + options.model = ai::anthropic::models::kClaudeSonnet5; options.prompt = "Please use the test_tool with input 'hello anthropic'"; options.tools = tools; options.tool_choice = @@ -158,7 +158,7 @@ void test_multi_step() { })}}; ai::GenerateOptions options; - options.model = ai::openai::models::kGpt54; + options.model = ai::openai::models::kGpt56; options.prompt = "Get a number and then multiply it by 2. Show me the steps."; options.tools = tools; options.max_steps = 5; // Enable multi-step diff --git a/examples/tool_calling_async.cpp b/examples/tool_calling_async.cpp index f911042..2510d1f 100644 --- a/examples/tool_calling_async.cpp +++ b/examples/tool_calling_async.cpp @@ -207,7 +207,7 @@ int main() { auto start_time = std::chrono::high_resolution_clock::now(); ai::GenerateOptions options1; - options1.model = ai::openai::models::kGpt54; + options1.model = ai::openai::models::kGpt56; options1.prompt = "Get me the latest tech news articles"; options1.tools = tools; options1.max_tokens = 200; @@ -234,7 +234,7 @@ int main() { start_time = std::chrono::high_resolution_clock::now(); ai::GenerateOptions options2; - options2.model = ai::openai::models::kGpt54; + options2.model = ai::openai::models::kGpt56; options2.prompt = R"( Please help me with these tasks: 1. Fetch the latest sports news @@ -271,7 +271,7 @@ int main() { start_time = std::chrono::high_resolution_clock::now(); ai::GenerateOptions options3; - options3.model = ai::openai::models::kGpt54; + options3.model = ai::openai::models::kGpt56; options3.prompt = R"( Please help me with these tasks: 1. Fetch tech news articles @@ -342,7 +342,7 @@ int main() { start_time = std::chrono::high_resolution_clock::now(); ai::GenerateOptions anthropic_options; - anthropic_options.model = ai::anthropic::models::kClaudeSonnet46; + anthropic_options.model = ai::anthropic::models::kClaudeSonnet5; anthropic_options.prompt = R"( Please help me with these THREE tasks. You MUST use the tools to complete ALL of them: 1. Use the fetch_news tool to get tech news articles diff --git a/examples/tool_calling_basic.cpp b/examples/tool_calling_basic.cpp index 7405c5d..385d5dc 100644 --- a/examples/tool_calling_basic.cpp +++ b/examples/tool_calling_basic.cpp @@ -89,7 +89,7 @@ int main() { std::cout << "Question: What's the weather like in San Francisco?\n\n"; ai::GenerateOptions options1; - options1.model = ai::openai::models::kGpt54; + options1.model = ai::openai::models::kGpt56; options1.prompt = "What's the weather like in San Francisco?"; options1.tools = tools; options1.max_tokens = 200; @@ -126,7 +126,7 @@ int main() { "weather like and what attractions should I visit?\n\n"; ai::GenerateOptions options2; - options2.model = ai::openai::models::kGpt54; + options2.model = ai::openai::models::kGpt56; options2.prompt = "I'm planning a trip to San Francisco. What's the weather like and what " "attractions should I visit?"; @@ -157,7 +157,7 @@ int main() { << "Question: Tell me about the weather (forced to use weather tool)\n\n"; ai::GenerateOptions options3; - options3.model = ai::openai::models::kGpt54; + options3.model = ai::openai::models::kGpt56; options3.prompt = "Tell me about the weather in New York"; options3.tools = tools; options3.tool_choice = @@ -179,7 +179,7 @@ int main() { std::cout << "Question: What's the weather like? (tools disabled)\n\n"; ai::GenerateOptions options4; - options4.model = ai::openai::models::kGpt54; + options4.model = ai::openai::models::kGpt56; options4.prompt = "What's the weather like in Boston?"; options4.tools = tools; options4.tool_choice = ai::ToolChoice::none(); // Disable tools diff --git a/examples/tool_calling_multistep.cpp b/examples/tool_calling_multistep.cpp index c91540a..236ebfc 100644 --- a/examples/tool_calling_multistep.cpp +++ b/examples/tool_calling_multistep.cpp @@ -181,7 +181,7 @@ int main() { "recommendations, and send her an email\n\n"; ai::GenerateOptions options1; - options1.model = ai::openai::models::kGpt54; + options1.model = ai::openai::models::kGpt56; options1.prompt = R"( Please help me with this task: 1. Look up the user 'alice' to get her information @@ -234,7 +234,7 @@ int main() { << "Task: Look up user 'bob' and create a personalized travel report\n\n"; ai::GenerateOptions options2; - options2.model = ai::openai::models::kGpt54; + options2.model = ai::openai::models::kGpt56; options2.prompt = R"( Create a personalized travel report for user 'bob': 1. Look up Bob's profile to get his location diff --git a/include/ai/ai.h b/include/ai/ai.h index 696c61b..f64a259 100644 --- a/include/ai/ai.h +++ b/include/ai/ai.h @@ -42,11 +42,10 @@ /// // Ensure OPENAI_API_KEY environment variable is set /// auto client = ai::openai::create_client(); /// -/// auto result = client.generate_text({ -/// .model = ai::openai::models::kGpt54, -/// .system = "You are a friendly assistant!", -/// .prompt = "Why is the sky blue?" -/// }); +/// ai::GenerateOptions options(ai::openai::models::kGpt56, +/// "Why is the sky blue?"); +/// options.system = "You are a friendly assistant!"; +/// auto result = client.generate_text(options); /// /// if (result) { /// std::cout << result->text << std::endl; @@ -57,15 +56,14 @@ /// ```cpp /// auto client = ai::openai::create_client(); /// -/// auto stream = client.stream_text({ -/// .model = ai::openai::models::kGpt54, -/// .system = "You are a helpful assistant.", -/// .prompt = "Write a short story about a robot." -/// }); +/// ai::GenerateOptions options(ai::openai::models::kGpt56, +/// "Write a short story about a robot."); +/// options.system = "You are a helpful assistant."; +/// auto stream = client.stream_text(ai::StreamOptions(options)); /// -/// for (const auto& chunk : stream) { -/// if (chunk.text) { -/// std::cout << chunk.text.value() << std::flush; +/// for (const auto& event : stream) { +/// if (event.is_text_delta()) { +/// std::cout << event.text_delta << std::flush; /// } /// } /// ``` @@ -73,11 +71,10 @@ /// Anthropic Integration: /// ```cpp /// auto client = ai::anthropic::create_client(); -/// auto result = client.generate_text({ -/// .model = ai::anthropic::models::kClaudeSonnet46, -/// .system = "You are a helpful assistant.", -/// .prompt = "Explain quantum computing in simple terms." -/// }); +/// ai::GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, +/// "Explain quantum computing in simple terms."); +/// options.system = "You are a helpful assistant."; +/// auto result = client.generate_text(options); /// /// if (result) { /// std::cout << result->text << std::endl; diff --git a/include/ai/anthropic.h b/include/ai/anthropic.h index 7815a2c..0a10da7 100644 --- a/include/ai/anthropic.h +++ b/include/ai/anthropic.h @@ -5,6 +5,7 @@ "Anthropic component not available. Link with ai::anthropic or ai::sdk to use Anthropic functionality." #endif +#include "retry/retry_policy.h" #include "types/client.h" #include @@ -14,30 +15,31 @@ namespace ai { namespace anthropic { namespace models { -/// Latest Anthropic model identifiers +/// Current Anthropic model identifiers +constexpr const char* kClaudeFable5 = "claude-fable-5"; +constexpr const char* kClaudeOpus5 = "claude-opus-5"; +constexpr const char* kClaudeOpus48 = "claude-opus-4-8"; +constexpr const char* kClaudeSonnet5 = "claude-sonnet-5"; constexpr const char* kClaudeOpus47 = "claude-opus-4-7"; constexpr const char* kClaudeSonnet46 = "claude-sonnet-4-6"; constexpr const char* kClaudeHaiku45 = "claude-haiku-4-5"; // claude-haiku-4-5-20251001 +constexpr const char* kClaudeHaiku45Snapshot = "claude-haiku-4-5-20251001"; /// Legacy model identifiers (still available; consider migrating) constexpr const char* kClaudeOpus46 = "claude-opus-4-6"; constexpr const char* kClaudeOpus45 = "claude-opus-4-5"; // claude-opus-4-5-20251101 +constexpr const char* kClaudeOpus45Snapshot = "claude-opus-4-5-20251101"; constexpr const char* kClaudeSonnet45 = "claude-sonnet-4-5"; // claude-sonnet-4-5-20250929 +constexpr const char* kClaudeSonnet45Snapshot = "claude-sonnet-4-5-20250929"; constexpr const char* kClaudeOpus41 = "claude-opus-4-1"; // claude-opus-4-1-20250805 - -/// Deprecated identifiers - scheduled for retirement on 2026-06-15. -/// Migrate to kClaudeSonnet46 / kClaudeOpus47 respectively. -constexpr const char* kClaudeSonnet4 = - "claude-sonnet-4-0"; // claude-sonnet-4-20250514 (DEPRECATED) -constexpr const char* kClaudeOpus4 = - "claude-opus-4-0"; // claude-opus-4-20250514 (DEPRECATED) +constexpr const char* kClaudeOpus41Snapshot = "claude-opus-4-1-20250805"; /// Default model used when none is specified -constexpr const char* kDefaultModel = kClaudeSonnet46; +constexpr const char* kDefaultModel = kClaudeSonnet5; } // namespace models /// Create an Anthropic client with default configuration @@ -56,6 +58,15 @@ Client create_client(const std::string& api_key); /// @return Configured Anthropic client Client create_client(const std::string& api_key, const std::string& base_url); +/// Create an Anthropic client with custom configuration and retry settings +/// @param api_key Anthropic API key +/// @param base_url Custom base URL (for Anthropic-compatible APIs) +/// @param retry_config Custom retry configuration +/// @return Configured Anthropic client +Client create_client(const std::string& api_key, + const std::string& base_url, + const retry::RetryConfig& retry_config); + /// Try to create an Anthropic client using environment variables /// Reads API key from ANTHROPIC_API_KEY environment variable /// @return Optional client - has value if environment variable is set, empty diff --git a/include/ai/core.h b/include/ai/core.h index 05fb2fc..2b8ecf8 100644 --- a/include/ai/core.h +++ b/include/ai/core.h @@ -31,9 +31,6 @@ /// #include /// /// // Use core types like ai::GenerateOptions, ai::Message, etc. -/// ai::GenerateOptions options{ -/// .model = "some-model", -/// .prompt = "Hello world" -/// }; +/// ai::GenerateOptions options("some-model", "Hello world"); /// ``` namespace ai {} \ No newline at end of file diff --git a/include/ai/logger.h b/include/ai/logger.h index 6aff531..7968a2d 100644 --- a/include/ai/logger.h +++ b/include/ai/logger.h @@ -107,21 +107,55 @@ class ConsoleLogger final : public Logger { namespace detail { +#if defined(__cpp_lib_atomic_shared_ptr) + +inline std::atomic>& logger_instance() { + static std::atomic> instance( + std::make_shared()); + return instance; +} + +inline std::shared_ptr load_logger() { + return logger_instance().load(); +} + +inline void store_logger(std::shared_ptr logger) { + logger_instance().store(std::move(logger)); +} + +#else +// Fall back to the atomic shared_ptr free functions on standard libraries +// that have not implemented std::atomic (notably libc++). + inline std::shared_ptr& logger_instance() { static std::shared_ptr instance = std::make_shared(); return instance; } +inline std::shared_ptr load_logger() { + return std::atomic_load(&logger_instance()); +} + +inline void store_logger(std::shared_ptr logger) { + std::atomic_store(&logger_instance(), std::move(logger)); +} + +#endif + } // namespace detail inline void install_logger(std::shared_ptr logger) { if (logger) { - std::atomic_store(&detail::logger_instance(), std::move(logger)); + detail::store_logger(std::move(logger)); } } +/// Returns the currently installed logger. The reference stays valid until +/// the next logger()/log_* call on the same thread; do not cache it across +/// calls that may log. inline Logger& logger() { - auto ptr = std::atomic_load(&detail::logger_instance()); + thread_local std::shared_ptr ptr; + ptr = detail::load_logger(); return *ptr; } diff --git a/include/ai/openai.h b/include/ai/openai.h index 54e39a9..1ed0df1 100644 --- a/include/ai/openai.h +++ b/include/ai/openai.h @@ -17,7 +17,14 @@ namespace openai { namespace models { /// Common OpenAI model identifiers -// GPT-5.4 series (current general-purpose) +// GPT-5.6 series (current frontier family) +constexpr const char* kGpt56 = "gpt-5.6"; // Alias for GPT-5.6 Sol +constexpr const char* kGpt56Sol = "gpt-5.6-sol"; +constexpr const char* kGpt56Terra = "gpt-5.6-terra"; +constexpr const char* kGpt56Luna = "gpt-5.6-luna"; + +// Earlier current GPT-5 series +constexpr const char* kGpt55 = "gpt-5.5"; constexpr const char* kGpt54 = "gpt-5.4"; constexpr const char* kGpt54Pro = "gpt-5.4-pro"; constexpr const char* kGpt54Mini = "gpt-5.4-mini"; @@ -35,41 +42,8 @@ constexpr const char* kGpt41Mini = "gpt-4.1-mini"; constexpr const char* kTextEmbedding3Small = "text-embedding-3-small"; constexpr const char* kTextEmbedding3Large = "text-embedding-3-large"; -/// Legacy / deprecated model identifiers (retained for backward compatibility). -/// These are scheduled for retirement; prefer the GPT-5 series above. - -// Deprecated GPT-5 snapshots (superseded by 5.4) -constexpr const char* kGpt5 = "gpt-5"; // DEPRECATED -constexpr const char* kGpt51 = "gpt-5.1"; // DEPRECATED -constexpr const char* kGpt52 = "gpt-5.2"; // DEPRECATED - -// Deprecated GPT-4.1 nano -constexpr const char* kGpt41Nano = "gpt-4.1-nano"; // DEPRECATED - -// Deprecated GPT-4o family (retired in ChatGPT; deprecated in API) -constexpr const char* kGpt4o = "gpt-4o"; // DEPRECATED -constexpr const char* kGpt4oMini = "gpt-4o-mini"; // DEPRECATED -constexpr const char* kGpt4oAudioPreview = - "gpt-4o-audio-preview"; // DEPRECATED -constexpr const char* kChatGpt4oLatest = "chatgpt-4o-latest"; // DEPRECATED - -// Deprecated GPT-4 series (shutdown 2026-10-23) -constexpr const char* kGpt4Turbo = "gpt-4-turbo"; // DEPRECATED -constexpr const char* kGpt4 = "gpt-4"; // DEPRECATED - -// Deprecated GPT-3.5 series (shutdown 2026-10-23) -constexpr const char* kGpt35Turbo = "gpt-3.5-turbo"; // DEPRECATED - -// Deprecated o-series reasoning models (shutdown 2026-10-23) -constexpr const char* kO1 = "o1"; // DEPRECATED -constexpr const char* kO1Mini = "o1-mini"; // DEPRECATED -constexpr const char* kO1Preview = "o1-preview"; // DEPRECATED -constexpr const char* kO3 = "o3"; // DEPRECATED -constexpr const char* kO3Mini = "o3-mini"; // DEPRECATED -constexpr const char* kO4Mini = "o4-mini"; // DEPRECATED - /// Default model used when none is specified -constexpr const char* kDefaultModel = kGpt54; +constexpr const char* kDefaultModel = kGpt56; } // namespace models diff --git a/include/ai/types/stream_event.h b/include/ai/types/stream_event.h index 6becd03..8646af3 100644 --- a/include/ai/types/stream_event.h +++ b/include/ai/types/stream_event.h @@ -1,6 +1,7 @@ #pragma once #include "enums.h" +#include "tool.h" #include "usage.h" #include @@ -20,12 +21,20 @@ struct StreamEvent { FinishReason reason) : type(event_type), usage(usage_stats), finish_reason(reason) {} + StreamEvent(StreamEventType event_type, FinishReason reason) + : type(event_type), finish_reason(reason) {} + explicit StreamEvent(StreamEventType event_type) : type(event_type) {} + explicit StreamEvent(ToolCall call) + : type(kStreamEventTypeToolCall), tool_call(std::move(call)) {} + bool is_text_delta() const { return type == kStreamEventTypeTextDelta; } bool is_error() const { return type == kStreamEventTypeError; } + bool is_tool_call() const { return type == kStreamEventTypeToolCall; } + bool is_finish() const { return type == kStreamEventTypeFinish; } StreamEventType type; @@ -33,7 +42,8 @@ struct StreamEvent { std::optional error; std::optional usage; std::optional finish_reason; + std::optional tool_call; std::optional metadata; }; -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/scripts/build.py b/scripts/build.py index cc1395a..dc6e854 100755 --- a/scripts/build.py +++ b/scripts/build.py @@ -30,7 +30,7 @@ import subprocess import sys from pathlib import Path -from typing import Optional +from typing import List, Optional import click from rich.console import Console @@ -41,7 +41,7 @@ console = Console() -def run_command(cmd: list[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess: +def run_command(cmd: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess: """Run a command and handle errors with rich output.""" console.print(f"[dim]Running:[/dim] [cyan]{' '.join(cmd)}[/cyan]") @@ -202,24 +202,28 @@ def main(mode: str, tests: bool, clean: bool, verbose: bool, export_compile_comm console.print() # Display build results + example_suffix = "_debug" if mode.lower() == "debug" else "" + tests_result = "" + if tests: + tests_result = f""" +[bold]To run tests:[/bold] + [cyan]cd {build_dir} && ctest --output-on-failure[/cyan] +""" + results_panel = Panel.fit( f"""[bold green]Build Results[/bold green] [bold]Built targets:[/bold] - ๐Ÿ“š Library: {build_dir}/libai-sdk-cpp.a (or .lib on Windows) + ๐Ÿ“š Libraries: {build_dir}/libai-sdk-cpp-*.a (or .lib on Windows) ๐ŸŽฏ Examples: {build_dir}/examples/ {f" ๐Ÿงช Tests: {build_dir}/tests/" if tests else ""} [bold]To run examples (after setting API keys):[/bold] [cyan]export OPENAI_API_KEY=your_openai_key[/cyan] [cyan]export ANTHROPIC_API_KEY=your_anthropic_key[/cyan] - [cyan]{build_dir}/examples/basic_chat[/cyan] - [cyan]{build_dir}/examples/streaming_chat[/cyan] - -{"""[bold]To run tests:[/bold] - [cyan]cd build && ctest[/cyan] - [cyan]cd build && ctest --verbose[/cyan] - [cyan]cd build && ctest -R "test_types"[/cyan] (run specific test)""" if tests else ""}""", + [cyan]{build_dir}/examples/basic_chat{example_suffix}[/cyan] + [cyan]{build_dir}/examples/streaming_chat{example_suffix}[/cyan] +{tests_result}""", title="๐ŸŽ‰ Success", border_style="green" ) @@ -228,4 +232,4 @@ def main(mode: str, tests: bool, clean: bool, verbose: bool, export_compile_comm if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/scripts/format.py b/scripts/format.py index f8ecc50..bc84345 100755 --- a/scripts/format.py +++ b/scripts/format.py @@ -34,7 +34,10 @@ def find_cpp_files() -> List[Path]: for ext in extensions: for file in project_dir.rglob(f"*{ext}"): # Skip files in excluded directories - if any(excluded in file.parts for excluded in exclude_dirs): + # Only directory components are checked for the "build-" prefix so + # source files like "build-info.cpp" are not skipped. + if any(part in exclude_dirs for part in file.parts) or any( + part.startswith("build-") for part in file.parts[:-1]): continue files.append(file) @@ -105,4 +108,4 @@ def main(check: bool): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/lint.py b/scripts/lint.py index f345ade..f6052f3 100755 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -4,7 +4,6 @@ # dependencies = [ # "click>=8.1.0", # "rich>=13.0.0", -# "asyncio", # ] # /// """Run clang-tidy on all C++ source files in parallel. @@ -82,7 +81,10 @@ def find_cpp_files() -> List[Path]: for ext in extensions: for file in project_dir.rglob(f"*{ext}"): # Skip files in excluded directories - if any(excluded in file.parts for excluded in exclude_dirs): + # Only directory components are checked for the "build-" prefix so + # source files like "build-info.cpp" are not skipped. + if any(part in exclude_dirs for part in file.parts) or any( + part.startswith("build-") for part in file.parts[:-1]): continue files.append(file) @@ -100,7 +102,7 @@ async def lint_file( async with semaphore: cmd = [ "clang-tidy", - f"-p={compile_commands}", + f"-p={compile_commands.parent}", *extra_args, ] @@ -221,4 +223,4 @@ def main(fix: bool, jobs: int): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/langfuse/tracer.cpp b/src/langfuse/tracer.cpp index d0e966d..503802f 100644 --- a/src/langfuse/tracer.cpp +++ b/src/langfuse/tracer.cpp @@ -532,7 +532,7 @@ std::string Trace::to_iso8601(std::chrono::system_clock::time_point t) { #else gmtime_r(&tt, &tm); #endif - char buf[40]; + char buf[80]; std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03lldZ", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(ms)); diff --git a/src/providers/anthropic/anthropic_client.cpp b/src/providers/anthropic/anthropic_client.cpp index c1e18bf..4516235 100644 --- a/src/providers/anthropic/anthropic_client.cpp +++ b/src/providers/anthropic/anthropic_client.cpp @@ -14,6 +14,18 @@ namespace anthropic { AnthropicClient::AnthropicClient(const std::string& api_key, const std::string& base_url) + : AnthropicClient(api_key, base_url, std::optional{}) {} + +AnthropicClient::AnthropicClient(const std::string& api_key, + const std::string& base_url, + const retry::RetryConfig& retry_config) + : AnthropicClient(api_key, + base_url, + std::optional(retry_config)) {} + +AnthropicClient::AnthropicClient(const std::string& api_key, + const std::string& base_url, + std::optional retry_config) : BaseProviderClient( providers::ProviderConfig{ .api_key = api_key, @@ -22,11 +34,13 @@ AnthropicClient::AnthropicClient(const std::string& api_key, .embeddings_endpoint_path = "/v1/embeddings", .auth_header_name = "x-api-key", .auth_header_prefix = "", - .extra_headers = {{"anthropic-version", "2023-06-01"}}}, + .extra_headers = {{"anthropic-version", "2023-06-01"}}, + .retry_config = retry_config}, std::make_unique(), std::make_unique()) { - ai::logger::log_debug("Anthropic client initialized with base_url: {}", - base_url); + ai::logger::log_debug("Anthropic client initialized with base_url: {}{}", + base_url, + config_.retry_config ? " and custom retry config" : ""); } StreamResult AnthropicClient::stream_text(const StreamOptions& options) { @@ -63,14 +77,14 @@ std::vector AnthropicClient::supported_models() const { // snapshot variants (e.g. "claude-sonnet-4-5-20250929") are accepted by the // Anthropic API; list both so identifiers in `ai::anthropic::models::*` // resolve via `supports_model()`. - return {"claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6", - "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-opus-4-5", - "claude-opus-4-5-20251101", "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", "claude-opus-4-1", - "claude-opus-4-1-20250805", - // Deprecated, retire 2026-06-15: - "claude-sonnet-4-0", "claude-sonnet-4-20250514", "claude-opus-4-0", - "claude-opus-4-20250514"}; + return {models::kClaudeFable5, models::kClaudeOpus5, + models::kClaudeOpus48, models::kClaudeSonnet5, + models::kClaudeOpus47, models::kClaudeSonnet46, + models::kClaudeOpus46, models::kClaudeHaiku45, + models::kClaudeHaiku45Snapshot, models::kClaudeOpus45, + models::kClaudeOpus45Snapshot, models::kClaudeSonnet45, + models::kClaudeSonnet45Snapshot, models::kClaudeOpus41, + models::kClaudeOpus41Snapshot}; } bool AnthropicClient::supports_model(const std::string& model_name) const { diff --git a/src/providers/anthropic/anthropic_client.h b/src/providers/anthropic/anthropic_client.h index f66e432..509f861 100644 --- a/src/providers/anthropic/anthropic_client.h +++ b/src/providers/anthropic/anthropic_client.h @@ -1,8 +1,10 @@ #pragma once +#include "ai/retry/retry_policy.h" #include "ai/types/stream_options.h" #include "providers/base_provider_client.h" +#include #include #include @@ -15,6 +17,10 @@ class AnthropicClient : public providers::BaseProviderClient { const std::string& api_key, const std::string& base_url = "https://api.anthropic.com"); + AnthropicClient(const std::string& api_key, + const std::string& base_url, + const retry::RetryConfig& retry_config); + // Override only what's specific to Anthropic StreamResult stream_text(const StreamOptions& options) override; std::string provider_name() const override; @@ -26,6 +32,11 @@ class AnthropicClient : public providers::BaseProviderClient { // Member access for testing const std::string& get_api_key() const { return config_.api_key; } const std::string& get_base_url() const { return config_.base_url; } + + private: + AnthropicClient(const std::string& api_key, + const std::string& base_url, + std::optional retry_config); }; } // namespace anthropic diff --git a/src/providers/anthropic/anthropic_factory.cpp b/src/providers/anthropic/anthropic_factory.cpp index bc6540c..4c358b0 100644 --- a/src/providers/anthropic/anthropic_factory.cpp +++ b/src/providers/anthropic/anthropic_factory.cpp @@ -2,8 +2,8 @@ #include "ai/errors.h" #include "anthropic_client.h" +#include "utils/env_utils.h" -#include #include #include @@ -18,13 +18,13 @@ std::string get_api_key_or_default(const std::string& api_key) { return api_key; } - const char* env_api_key = std::getenv("ANTHROPIC_API_KEY"); + auto env_api_key = utils::non_empty_env("ANTHROPIC_API_KEY"); if (!env_api_key) { throw ConfigurationError( "API key not provided and ANTHROPIC_API_KEY environment variable not " "set"); } - return env_api_key; + return *std::move(env_api_key); } std::string get_base_url_or_default(const std::string& base_url) { @@ -47,13 +47,21 @@ Client create_client(const std::string& api_key, const std::string& base_url) { get_api_key_or_default(api_key), get_base_url_or_default(base_url))); } +Client create_client(const std::string& api_key, + const std::string& base_url, + const retry::RetryConfig& retry_config) { + return Client(std::make_unique( + get_api_key_or_default(api_key), get_base_url_or_default(base_url), + retry_config)); +} + std::optional try_create_client() { - const char* api_key = std::getenv("ANTHROPIC_API_KEY"); + auto api_key = utils::non_empty_env("ANTHROPIC_API_KEY"); if (!api_key) { return std::nullopt; } - return Client(std::make_unique(api_key, kDefaultBaseUrl)); + return Client(std::make_unique(*api_key, kDefaultBaseUrl)); } } // namespace anthropic -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/anthropic/anthropic_request_builder.cpp b/src/providers/anthropic/anthropic_request_builder.cpp index 2933c58..f63fd09 100644 --- a/src/providers/anthropic/anthropic_request_builder.cpp +++ b/src/providers/anthropic/anthropic_request_builder.cpp @@ -1,8 +1,13 @@ #include "anthropic_request_builder.h" +#include "ai/anthropic.h" #include "ai/logger.h" #include "utils/message_utils.h" +#include +#include +#include + namespace ai { namespace anthropic { @@ -92,13 +97,32 @@ nlohmann::json AnthropicRequestBuilder::build_request_json( } // Add optional parameters - if (options.temperature) { - request["temperature"] = *options.temperature; - } - - if (options.top_p) { - request["top_p"] = *options.top_p; - } + // Recent Anthropic models reject sampling controls. Keep accepting the + // provider-neutral options while omitting them for those model IDs. + constexpr std::array kSamplingRejectingModelPrefixes = { + models::kClaudeOpus5, models::kClaudeOpus48, models::kClaudeOpus47, + models::kClaudeFable5, models::kClaudeSonnet5}; + const bool rejects_sampling_parameters = std::any_of( + kSamplingRejectingModelPrefixes.begin(), + kSamplingRejectingModelPrefixes.end(), [&options](const char* prefix) { + return options.model.starts_with(prefix); + }); + const auto add_sampling_parameter = [&](const char* key, + const std::optional& value) { + if (!value) { + return; + } + if (rejects_sampling_parameters) { + ai::logger::log_warn( + "Ignoring {} for {} because the model does not support " + "sampling parameters", + key, options.model); + } else { + request[key] = *value; + } + }; + add_sampling_parameter("temperature", options.temperature); + add_sampling_parameter("top_p", options.top_p); // Anthropic uses top_k instead of top_p for some control if (options.seed) { diff --git a/src/providers/anthropic/anthropic_response_parser.cpp b/src/providers/anthropic/anthropic_response_parser.cpp index 4789730..a78bbe1 100644 --- a/src/providers/anthropic/anthropic_response_parser.cpp +++ b/src/providers/anthropic/anthropic_response_parser.cpp @@ -103,7 +103,7 @@ EmbeddingResult AnthropicResponseParser::parse_success_embedding_response( // Extract choices if (response.contains("data") && !response["data"].empty()) { - result.data = std::move(response["data"]); + result.data = response["data"]; } // Extract usage @@ -150,4 +150,4 @@ FinishReason AnthropicResponseParser::parse_stop_reason( } } // namespace anthropic -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/anthropic/anthropic_stream.cpp b/src/providers/anthropic/anthropic_stream.cpp index 049d7f4..5a3b6a6 100644 --- a/src/providers/anthropic/anthropic_stream.cpp +++ b/src/providers/anthropic/anthropic_stream.cpp @@ -2,168 +2,72 @@ #include "ai/logger.h" -#include -#include -#include - namespace { -constexpr auto kEventTimeout = static_cast(30); -constexpr auto kSleepInterval = std::chrono::milliseconds(1); +// Null-tolerant JSON field accessors: gateways may deliver fields as JSON +// null, which nlohmann's value() would throw on (discarding the whole event). +int int_or_zero(const nlohmann::json& obj, const char* key) { + const auto it = obj.find(key); + return it != obj.end() && it->is_number() ? it->get() : 0; +} + +std::string string_or_empty(const nlohmann::json& obj, const char* key) { + const auto it = obj.find(key); + return it != obj.end() && it->is_string() ? it->get() + : std::string{}; +} + +std::size_t index_or_zero(const nlohmann::json& obj) { + const auto it = obj.find("index"); + return it != obj.end() && it->is_number_unsigned() ? it->get() + : std::size_t{0}; +} } // namespace namespace ai { namespace anthropic { AnthropicStreamImpl::~AnthropicStreamImpl() { + // Join before this class's state is destroyed; the stream thread calls + // process_sse_line. stop_stream(); } -void AnthropicStreamImpl::start_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body) { - ai::logger::log_debug("Starting Anthropic stream to URL: {}", url); - - // Start streaming in a separate thread - stream_thread_ = std::thread([this, url, headers, request_body]() { - try { - run_stream(url, headers, request_body); - } catch (const std::exception& e) { - ai::logger::log_error("Stream thread exception: {}", e.what()); - StreamEvent error_event(kStreamEventTypeError, - std::string("Stream error: ") + e.what()); - push_event(error_event); - mark_complete(); - } - }); +void AnthropicStreamImpl::reset_stream_state() { + event_data_.clear(); } -StreamEvent AnthropicStreamImpl::get_next_event() { - StreamEvent event(""); - auto start_time = std::chrono::steady_clock::now(); - - while (!event_queue_.try_dequeue(event)) { - if (stream_complete_ && event_queue_.size_approx() == 0) { - // Stream is complete and queue is empty - ai::logger::log_debug( - "Stream complete and queue empty, returning empty event"); - return StreamEvent(""); +void AnthropicStreamImpl::process_sse_line(std::string_view line) { + if (line.empty()) { + // Blank line terminates the SSE event + if (!event_data_.empty()) { + process_sse_event(event_data_); + event_data_.clear(); } - - // Check for timeout - if (std::chrono::steady_clock::now() - start_time > kEventTimeout) { - ai::logger::log_error( - "Timeout waiting for next stream event after {} seconds", - kEventTimeout.count()); - return StreamEvent(kStreamEventTypeError, - "Timeout waiting for next event"); + } else if (line.starts_with("data:")) { + auto data = line.substr(5); + if (!data.empty() && data.front() == ' ') { + data.remove_prefix(1); } - - std::this_thread::sleep_for(kSleepInterval); - } - - ai::logger::log_debug("Dequeued event type: {}", - static_cast(event.type)); - return event; -} - -bool AnthropicStreamImpl::has_more_events() const { - return event_queue_.size_approx() > 0 || !stream_complete_; -} - -void AnthropicStreamImpl::stop_stream() { - ai::logger::log_debug("Stopping Anthropic stream"); - stop_requested_ = true; - if (stream_thread_.joinable()) { - stream_thread_.join(); + if (!event_data_.empty()) { + event_data_ += '\n'; + } + event_data_.append(data); } } -void AnthropicStreamImpl::run_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body) { - ai::logger::log_debug("Performing stream request"); - - // Parse URL to extract host and path - std::string host, path; - bool use_ssl = true; - - if (url.starts_with("https://")) { - host = url.substr(8); - use_ssl = true; - } else if (url.starts_with("http://")) { - host = url.substr(7); - use_ssl = false; - } - - if (auto pos = host.find('/'); pos != std::string::npos) { - path = host.substr(pos); - host = host.substr(0, pos); - } else { - path = "/v1/messages"; - } - - ai::logger::log_debug("Stream host: {}, path: {}, SSL: {}", host, path, - use_ssl); - - try { - if (use_ssl) { - httplib::SSLClient client(host); - client.enable_server_certificate_verification(true); - client.set_connection_timeout(30, 0); - client.set_read_timeout(120, 0); - - auto result = - client.Post(path, headers, request_body.dump(), "application/json"); - - if (result && result->status == 200) { - parse_sse_response(result->body); - } else { - handle_stream_error(result ? result->status : 0, - result ? result->body : "Connection failed"); - } - } else { - httplib::Client client(host); - client.set_connection_timeout(30, 0); - client.set_read_timeout(120, 0); - - auto result = - client.Post(path, headers, request_body.dump(), "application/json"); - - if (result && result->status == 200) { - parse_sse_response(result->body); - } else { - handle_stream_error(result ? result->status : 0, - result ? result->body : "Connection failed"); - } - } - } catch (const std::exception& e) { - ai::logger::log_error("Stream request exception: {}", e.what()); - handle_stream_error(0, std::string("Request failed: ") + e.what()); +void AnthropicStreamImpl::finalize_stream() { + // Dispatch an event whose terminating blank line never arrived. + if (!event_data_.empty()) { + process_sse_event(event_data_); + event_data_.clear(); } - - mark_complete(); } -void AnthropicStreamImpl::parse_sse_response(const std::string& response) { - ai::logger::log_debug("Processing SSE response, size: {}", response.size()); - - std::istringstream stream(response); - std::string line; - std::string event_data; - - while (std::getline(stream, line) && !stop_requested_) { - if (line.empty()) { - // Empty line signals end of event - if (!event_data.empty()) { - process_sse_event(event_data); - event_data.clear(); - } - } else if (line.starts_with("data: ")) { - event_data = line.substr(6); - } +Usage& AnthropicStreamImpl::ensure_usage() { + if (!usage_) { + usage_ = Usage{}; } - - ai::logger::log_debug("SSE processing complete"); + return *usage_; } void AnthropicStreamImpl::process_sse_event(const std::string& data) { @@ -174,72 +78,91 @@ void AnthropicStreamImpl::process_sse_event(const std::string& data) { try { auto json_event = nlohmann::json::parse(data); - std::string event_type = json_event.value("type", ""); + std::string event_type = string_or_empty(json_event, "type"); ai::logger::log_debug("Processing SSE event type: {}", event_type); if (event_type == "message_start") { - // Start of message - could extract metadata here + if (json_event.contains("message") && json_event["message"].is_object()) { + const auto& message = json_event["message"]; + if (message.contains("usage") && message["usage"].is_object()) { + auto& usage = ensure_usage(); + usage.prompt_tokens = int_or_zero(message["usage"], "input_tokens"); + usage.total_tokens = usage.prompt_tokens + usage.completion_tokens; + } + } return; } else if (event_type == "content_block_start") { - // Start of content block + if (json_event.contains("content_block") && + json_event["content_block"].is_object() && + string_or_empty(json_event["content_block"], "type") == "tool_use") { + const auto index = index_or_zero(json_event); + const auto& block = json_event["content_block"]; + auto& pending = pending_tool_calls_[index]; + pending.id = string_or_empty(block, "id"); + pending.name = string_or_empty(block, "name"); + if (block.contains("input") && block["input"].is_object() && + !block["input"].empty()) { + // Keep separately from the delta accumulator: appending deltas to + // an already complete JSON object would produce invalid JSON. + pending.initial_input = block["input"].dump(); + } + } return; } else if (event_type == "content_block_delta") { // Text delta - this is what we want to stream - if (json_event.contains("delta") && - json_event["delta"].contains("text")) { + if (json_event.contains("delta") && json_event["delta"].is_object() && + json_event["delta"].contains("text") && + json_event["delta"]["text"].is_string()) { std::string text = json_event["delta"]["text"]; - StreamEvent event(text); - push_event(event); + push_event(StreamEvent(text)); ai::logger::log_debug("Enqueued text delta: '{}'", text); + } else if (json_event.contains("delta") && + json_event["delta"].is_object() && + string_or_empty(json_event["delta"], "type") == + "input_json_delta") { + const auto index = index_or_zero(json_event); + pending_tool_calls_[index].arguments += + string_or_empty(json_event["delta"], "partial_json"); } } else if (event_type == "content_block_stop") { - // End of content block + flush_pending_tool_call(index_or_zero(json_event)); return; } else if (event_type == "message_delta") { - // Message-level delta (could contain stop reason) + if (json_event.contains("delta") && json_event["delta"].is_object()) { + const auto stop_reason = + string_or_empty(json_event["delta"], "stop_reason"); + if (stop_reason == "max_tokens") { + finish_reason_ = kFinishReasonLength; + } else if (stop_reason == "tool_use") { + finish_reason_ = kFinishReasonToolCalls; + } else if (!stop_reason.empty()) { + finish_reason_ = kFinishReasonStop; + } + } + if (json_event.contains("usage") && json_event["usage"].is_object()) { + auto& usage = ensure_usage(); + usage.completion_tokens = + int_or_zero(json_event["usage"], "output_tokens"); + usage.total_tokens = usage.prompt_tokens + usage.completion_tokens; + } return; } else if (event_type == "message_stop") { - // End of message - StreamEvent event(kStreamEventTypeFinish, Usage{}, kFinishReasonStop); - push_event(event); - - ai::logger::log_debug("Enqueued finish event"); + // The stream is semantically finished; complete immediately so + // consumers are not left waiting for the connection to close. + mark_complete(); + } else if (event_type == "error") { + const auto message = json_event.value("error", nlohmann::json::object()) + .value("message", "Anthropic stream error"); + note_stream_error(); + push_event(create_error_event(message)); } } catch (const std::exception& e) { ai::logger::log_error("Failed to parse SSE event: {}", e.what()); } } -void AnthropicStreamImpl::push_event(const StreamEvent& event) { - event_queue_.enqueue(event); -} - -void AnthropicStreamImpl::mark_complete() { - stream_complete_ = true; - - // Push final finish event if not already done - StreamEvent finish_event(kStreamEventTypeFinish); - push_event(finish_event); -} - -StreamEvent AnthropicStreamImpl::create_error_event( - const std::string& message) { - return StreamEvent(kStreamEventTypeError, message); -} - -void AnthropicStreamImpl::handle_stream_error(int status_code, - const std::string& error_body) { - ai::logger::log_error("Stream error - status: {}, body: {}", status_code, - error_body); - - StreamEvent error_event( - kStreamEventTypeError, - "Stream error (" + std::to_string(status_code) + "): " + error_body); - push_event(error_event); -} - } // namespace anthropic -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/anthropic/anthropic_stream.h b/src/providers/anthropic/anthropic_stream.h index c4c2ae4..74d6eb1 100644 --- a/src/providers/anthropic/anthropic_stream.h +++ b/src/providers/anthropic/anthropic_stream.h @@ -1,55 +1,33 @@ #pragma once -#include "ai/types/stream_result.h" +#include "providers/http_sse_stream.h" -#include -#include -#include -#include -#include - -#include +#include +#include namespace ai { namespace anthropic { -class AnthropicStreamImpl : public internal::StreamResultImpl { +class AnthropicStreamImpl : public providers::streaming::HttpSseStream { public: - AnthropicStreamImpl() = default; - ~AnthropicStreamImpl(); - - // Non-copyable, non-movable for thread safety - AnthropicStreamImpl(const AnthropicStreamImpl&) = delete; - AnthropicStreamImpl& operator=(const AnthropicStreamImpl&) = delete; - AnthropicStreamImpl(AnthropicStreamImpl&&) = delete; - AnthropicStreamImpl& operator=(AnthropicStreamImpl&&) = delete; - - void start_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body); - - StreamEvent get_next_event() override; - bool has_more_events() const override; - void stop_stream() override; + AnthropicStreamImpl() + : HttpSseStream({.default_path = "/v1/messages", + .connection_timeout_seconds = 30, + .read_timeout_seconds = 120}) {} + ~AnthropicStreamImpl() override; private: - void run_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body); - void parse_sse_response(const std::string& response); - void process_sse_event(const std::string& data); - void push_event(const StreamEvent& event); - void mark_complete(); + void reset_stream_state() override; + void process_sse_line(std::string_view line) override; + void finalize_stream() override; - // Helper functions - StreamEvent create_error_event(const std::string& message); - void handle_stream_error(int status_code, const std::string& error_body); + void process_sse_event(const std::string& data); + Usage& ensure_usage(); - moodycamel::ConcurrentQueue event_queue_; - std::thread stream_thread_; - std::atomic stop_requested_{false}; - std::atomic stream_complete_{false}; + // Anthropic SSE events span multiple "data:" lines; accumulate them here + // until the blank line that terminates the event. + std::string event_data_; }; } // namespace anthropic -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/base_provider_client.cpp b/src/providers/base_provider_client.cpp index 9484724..24c16d1 100644 --- a/src/providers/base_provider_client.cpp +++ b/src/providers/base_provider_client.cpp @@ -140,6 +140,7 @@ GenerateResult BaseProviderClient::generate_text_single_step( } StreamResult BaseProviderClient::stream_text(const StreamOptions& options) { + (void)options; // This needs to be implemented with provider-specific stream implementations // For now, return an error ai::logger::log_error("Streaming not yet implemented in BaseProviderClient"); @@ -197,4 +198,4 @@ EmbeddingResult BaseProviderClient::embeddings( } } // namespace providers -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/http_sse_stream.cpp b/src/providers/http_sse_stream.cpp new file mode 100644 index 0000000..292ef80 --- /dev/null +++ b/src/providers/http_sse_stream.cpp @@ -0,0 +1,268 @@ +#include "http_sse_stream.h" + +#include "ai/logger.h" + +#include +#include + +namespace { +constexpr auto kEventTimeout = static_cast(30); +constexpr auto kSleepInterval = std::chrono::milliseconds(1); +} // namespace + +namespace ai { +namespace providers { +namespace streaming { + +HttpSseStream::HttpSseStream(Options options) : options_(std::move(options)) {} + +HttpSseStream::~HttpSseStream() { + // Concrete classes join in their own destructor (their state is still alive + // for the stream thread there); this is a backstop for the base state. + stop_stream(); +} + +void HttpSseStream::start_stream(const std::string& url, + const httplib::Headers& headers, + const nlohmann::json& request_body) { + ai::logger::log_debug("Starting stream to URL: {}", url); + + std::lock_guard lock(thread_mutex_); + + if (stream_thread_.joinable()) { + ai::logger::log_debug( + "Stream thread already running, not starting new one"); + return; // Already running + } + + // Reset state for new stream + should_stop_ = false; + is_complete_ = false; + finish_event_pushed_ = false; + stream_errored_ = false; + line_buffer_.clear(); + pending_tool_calls_.clear(); + finish_reason_ = kFinishReasonStop; + usage_.reset(); + reset_stream_state(); + + ai::logger::log_info("Launching stream thread"); + + stream_thread_ = std::thread([this, url, headers, request_body]() { + run_stream(url, headers, request_body); + }); +} + +StreamEvent HttpSseStream::get_next_event() { + StreamEvent event(""); + auto start_time = std::chrono::steady_clock::now(); + + while (!event_queue_.try_dequeue(event)) { + if (is_complete_ && event_queue_.size_approx() == 0) { + // Stream is complete and queue is empty + ai::logger::log_debug( + "Stream complete and queue empty, returning empty event"); + return StreamEvent(""); + } + + // Check for timeout + if (std::chrono::steady_clock::now() - start_time > kEventTimeout) { + ai::logger::log_error( + "Timeout waiting for next stream event after {} seconds", + kEventTimeout.count()); + return StreamEvent(kStreamEventTypeError, + "Timeout waiting for next event"); + } + + std::this_thread::sleep_for(kSleepInterval); + } + + ai::logger::log_debug("Dequeued event type: {}", + static_cast(event.type)); + return event; +} + +bool HttpSseStream::has_more_events() const { + // No locks needed - these are atomic operations + return event_queue_.size_approx() > 0 || !is_complete_; +} + +void HttpSseStream::stop_stream() { + ai::logger::log_debug("Stopping stream"); + + should_stop_ = true; // Atomic write + + std::lock_guard lock(thread_mutex_); + if (stream_thread_.joinable()) { + ai::logger::log_debug("Waiting for stream thread to finish"); + stream_thread_.join(); + ai::logger::log_info("Stream stopped successfully"); + } +} + +void HttpSseStream::run_stream(const std::string& url, + const httplib::Headers& headers, + const nlohmann::json& request_body) { + // Extract origin and path from URL. Using httplib::Client with the scheme + // preserved supports both HTTPS provider endpoints and HTTP test/gateway + // endpoints. + const auto [origin, path] = split_origin_and_path(url, options_.default_path); + + ai::logger::log_debug( + "Stream thread started - connecting to {} with path: {}", origin, path); + + try { + httplib::Client client(origin); + client.enable_server_certificate_verification(true); + client.set_connection_timeout(options_.connection_timeout_seconds, 0); + client.set_read_timeout(options_.read_timeout_seconds, 0); + + int response_status = 0; + std::string error_body; + + httplib::Request request; + request.method = "POST"; + request.path = path; + request.headers = headers; + request.body = request_body.dump(); + request.set_header("Content-Type", "application/json"); + + ai::logger::log_debug( + "Stream request prepared - path: {}, body size: {} bytes", path, + request.body.length()); + + // Capture the status before the body arrives so error bodies can be + // preserved (httplib routes the body of every status through the content + // receiver, leaving response.body empty). + request.response_handler = + [&response_status](const httplib::Response& response) { + response_status = response.status; + return true; + }; + + request.content_receiver = [this, &response_status, &error_body]( + const char* data, size_t length, + uint64_t /*offset*/, + uint64_t /*total_length*/) { + if (should_stop_) { + return false; + } + if (response_status != 200) { + // Error responses are not SSE; keep the body for diagnostics. + error_body.append(data, length); + return true; + } + consume_chunk(data, length); + return !should_stop_; + }; + + httplib::Response response; + httplib::Error error; + + ai::logger::log_info("Sending stream request"); + + if (!client.send(request, response, error)) { + // A deliberate stop makes the receiver return false (Error::Canceled); + // do not report that as a network failure. + if (!should_stop_) { + std::string message = "Network error: " + httplib::to_string(error); + ai::logger::log_error("Failed to send stream request: {}", message); + note_stream_error(); + push_event(create_error_event(message)); + } + } else if (response.status != 200) { + ai::logger::log_error("Stream API returned status {} - body: {}", + response.status, error_body); + note_stream_error(); + push_event(create_error_event("HTTP " + std::to_string(response.status) + + " error: " + error_body)); + } else { + if (!line_buffer_.empty()) { + // Process a trailing line that arrived without a final newline. + consume_chunk("\n", 1); + } + if (!stream_errored_) { + finalize_stream(); + // Tool calls whose terminating event never arrived (some gateways + // close the connection early) must not be silently dropped. + flush_all_pending_tool_calls(); + } + ai::logger::log_info("Stream completed successfully"); + } + } catch (const std::exception& e) { + ai::logger::log_error("Exception in stream thread: {}", e.what()); + note_stream_error(); + push_event(create_error_event(e.what())); + } + + mark_complete(); + ai::logger::log_debug("Stream thread exiting"); +} + +void HttpSseStream::consume_chunk(const char* data, std::size_t length) { + line_buffer_.append(data, length); + + std::size_t start = 0; + std::size_t newline = 0; + while ((newline = line_buffer_.find('\n', start)) != std::string::npos) { + std::string_view line(line_buffer_.data() + start, newline - start); + start = newline + 1; + if (!line.empty() && line.back() == '\r') { + line.remove_suffix(1); + } + process_sse_line(line); + } + line_buffer_.erase(0, start); +} + +void HttpSseStream::push_event(StreamEvent event) { + event_queue_.enqueue(std::move(event)); +} + +StreamEvent HttpSseStream::create_error_event(const std::string& message) { + ai::logger::log_debug("Creating error event: {}", message); + return StreamEvent(kStreamEventTypeError, message); +} + +void HttpSseStream::push_finish_event_if_needed() { + bool expected = false; + if (!finish_event_pushed_.compare_exchange_strong(expected, true)) { + ai::logger::log_debug("Finish event already pushed, skipping"); + return; + } + + ai::logger::log_debug("Pushing finish event to queue"); + if (stream_errored_) { + // Do not stamp a failed stream with a clean finish reason or usage. + push_event(StreamEvent(kStreamEventTypeFinish, kFinishReasonError)); + } else if (usage_) { + push_event(StreamEvent(kStreamEventTypeFinish, *usage_, finish_reason_)); + } else { + push_event(StreamEvent(kStreamEventTypeFinish, finish_reason_)); + } +} + +void HttpSseStream::mark_complete() { + push_finish_event_if_needed(); + is_complete_ = true; // Atomic write +} + +void HttpSseStream::flush_all_pending_tool_calls() { + for (const auto& entry : pending_tool_calls_) { + push_event(make_tool_call_event(entry.second)); + } + pending_tool_calls_.clear(); +} + +void HttpSseStream::flush_pending_tool_call(std::size_t index) { + const auto pending_it = pending_tool_calls_.find(index); + if (pending_it == pending_tool_calls_.end()) { + return; + } + push_event(make_tool_call_event(pending_it->second)); + pending_tool_calls_.erase(pending_it); +} + +} // namespace streaming +} // namespace providers +} // namespace ai diff --git a/src/providers/http_sse_stream.h b/src/providers/http_sse_stream.h new file mode 100644 index 0000000..6b9bb7d --- /dev/null +++ b/src/providers/http_sse_stream.h @@ -0,0 +1,121 @@ +#pragma once + +#include "ai/types/stream_result.h" +#include "providers/stream_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ai { +namespace providers { +namespace streaming { + +/// Threaded HTTP SSE stream shared by the provider stream implementations. +/// Owns the event queue, the stream thread, and the HTTP request plus SSE +/// line-splitting plumbing; derived classes only translate provider-specific +/// SSE lines into stream events. +/// +/// Concrete classes must call stop_stream() in their destructor so the stream +/// thread is joined before their members are destroyed. +class HttpSseStream : public internal::StreamResultImpl { + public: + ~HttpSseStream() override; + + // Non-copyable, non-movable for thread safety + HttpSseStream(const HttpSseStream&) = delete; + HttpSseStream& operator=(const HttpSseStream&) = delete; + HttpSseStream(HttpSseStream&&) = delete; + HttpSseStream& operator=(HttpSseStream&&) = delete; + + void start_stream(const std::string& url, + const httplib::Headers& headers, + const nlohmann::json& request_body); + + StreamEvent get_next_event() override; + bool has_more_events() const override; + void stop_stream() override; + +#ifdef AI_SDK_TESTING + void process_sse_chunk_for_testing(const std::string& chunk) { + consume_chunk(chunk.data(), chunk.size()); + } + void process_sse_line_for_testing(const std::string& line) { + process_sse_line(line); + } + std::size_t queued_event_count_for_testing() const { + return event_queue_.size_approx(); + } +#endif + + protected: + struct Options { + std::string default_path; // Used when the URL carries no path. + time_t connection_timeout_seconds{30}; + time_t read_timeout_seconds{120}; + }; + + explicit HttpSseStream(Options options); + + /// Reset provider-specific parse state before a new stream starts. + virtual void reset_stream_state() {} + + /// Handle one decoded SSE line (trailing CR already stripped). + virtual void process_sse_line(std::string_view line) = 0; + + /// Called after a successful response body has been fully consumed, before + /// pending tool calls are flushed and the finish event is pushed; flush any + /// provider-side leftovers here. + virtual void finalize_stream() {} + + void push_event(StreamEvent event); + static StreamEvent create_error_event(const std::string& message); + /// Push the terminal finish event (at most once per stream). + void push_finish_event_if_needed(); + /// Push the terminal finish event if still needed and mark the stream + /// complete so consumers stop waiting for events. + void mark_complete(); + /// Record that the stream failed; the finish event will carry + /// kFinishReasonError and no usage. + void note_stream_error() { stream_errored_ = true; } + + /// Emit all accumulated tool calls (or parse-error events) and clear them. + void flush_all_pending_tool_calls(); + /// Emit one accumulated tool call by content-block index, if present. + void flush_pending_tool_call(std::size_t index); + + // Terminal-finish payload and tool-call accumulator, maintained by the + // derived parser and consumed when the finish event is pushed. + FinishReason finish_reason_{kFinishReasonStop}; + std::optional usage_; + std::map pending_tool_calls_; + + private: + void run_stream(const std::string& url, + const httplib::Headers& headers, + const nlohmann::json& request_body); + void consume_chunk(const char* data, std::size_t length); + + Options options_; + moodycamel::ConcurrentQueue event_queue_; + std::thread stream_thread_; + std::mutex thread_mutex_; + std::string line_buffer_; + std::atomic is_complete_{false}; + std::atomic should_stop_{false}; + std::atomic finish_event_pushed_{false}; + std::atomic stream_errored_{false}; +}; + +} // namespace streaming +} // namespace providers +} // namespace ai diff --git a/src/providers/openai/openai_client.cpp b/src/providers/openai/openai_client.cpp index 7fbc685..ef52005 100644 --- a/src/providers/openai/openai_client.cpp +++ b/src/providers/openai/openai_client.cpp @@ -22,7 +22,8 @@ OpenAIClient::OpenAIClient(const std::string& api_key, .embeddings_endpoint_path = "/v1/embeddings", .auth_header_name = "Authorization", .auth_header_prefix = "Bearer ", - .extra_headers = {}}, + .extra_headers = {}, + .retry_config = std::nullopt}, std::make_unique(), std::make_unique()) { ai::logger::log_debug("OpenAI client initialized with base_url: {}", @@ -57,6 +58,9 @@ StreamResult OpenAIClient::stream_text(const StreamOptions& options) { // Build request with stream: true auto request_json = request_builder_->build_request_json(options); request_json["stream"] = true; + // Request the terminal usage chunk; without this OpenAI omits usage from + // streaming responses and the finish event would never carry token counts. + request_json["stream_options"] = {{"include_usage", true}}; ai::logger::log_debug("Stream request JSON built with stream=true"); // Create headers @@ -80,13 +84,12 @@ std::string OpenAIClient::provider_name() const { std::vector OpenAIClient::supported_models() const { return {// Current GPT-5 series - models::kGpt54, models::kGpt54Pro, models::kGpt54Mini, - models::kGpt54Nano, models::kGpt5Mini, models::kGpt5Nano, + models::kGpt56, models::kGpt56Sol, models::kGpt56Terra, + models::kGpt56Luna, models::kGpt55, models::kGpt54, models::kGpt54Pro, + models::kGpt54Mini, models::kGpt54Nano, models::kGpt5Mini, + models::kGpt5Nano, // Current GPT-4.1 series - models::kGpt41, models::kGpt41Mini, - // Legacy / deprecated (still functional via API) - models::kGpt4o, models::kGpt4oMini, models::kGpt4Turbo, models::kGpt4, - models::kGpt35Turbo}; + models::kGpt41, models::kGpt41Mini}; } bool OpenAIClient::supports_model(const std::string& model_name) const { diff --git a/src/providers/openai/openai_factory.cpp b/src/providers/openai/openai_factory.cpp index 808e841..da9eb28 100644 --- a/src/providers/openai/openai_factory.cpp +++ b/src/providers/openai/openai_factory.cpp @@ -2,8 +2,8 @@ #include "ai/errors.h" #include "openai_client.h" +#include "utils/env_utils.h" -#include #include #include @@ -18,12 +18,12 @@ std::string get_api_key_or_default(const std::string& api_key) { return api_key; } - const char* env_api_key = std::getenv("OPENAI_API_KEY"); + auto env_api_key = utils::non_empty_env("OPENAI_API_KEY"); if (!env_api_key) { throw ConfigurationError( "API key not provided and OPENAI_API_KEY environment variable not set"); } - return env_api_key; + return *std::move(env_api_key); } std::string get_base_url_or_default(const std::string& base_url) { @@ -55,12 +55,12 @@ Client create_client(const std::string& api_key, } std::optional try_create_client() { - const char* api_key = std::getenv("OPENAI_API_KEY"); + auto api_key = utils::non_empty_env("OPENAI_API_KEY"); if (!api_key) { return std::nullopt; } - return Client(std::make_unique(api_key, kDefaultBaseUrl)); + return Client(std::make_unique(*api_key, kDefaultBaseUrl)); } } // namespace openai -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/openai/openai_request_builder.cpp b/src/providers/openai/openai_request_builder.cpp index 16a06b6..224db6f 100644 --- a/src/providers/openai/openai_request_builder.cpp +++ b/src/providers/openai/openai_request_builder.cpp @@ -1,6 +1,7 @@ #include "openai_request_builder.h" #include "ai/logger.h" +#include "ai/openai.h" #include "utils/message_utils.h" namespace ai { @@ -120,6 +121,14 @@ nlohmann::json OpenAIRequestBuilder::build_request_json( request["seed"] = *options.seed; } + // GPT-5.6 defaults to reasoning in Chat Completions. That mode does not + // support function tools and can consume the full completion budget without + // producing user-visible text. The unified SDK currently exposes Chat + // Completions semantics, so explicitly request non-reasoning output. + if (options.model.starts_with(models::kGpt56)) { + request["reasoning_effort"] = "none"; + } + // Add tools if specified if (options.has_tools()) { ai::logger::log_debug("Adding {} tools to request", options.tools.size()); @@ -214,4 +223,4 @@ httplib::Headers OpenAIRequestBuilder::build_headers( } } // namespace openai -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/openai/openai_response_parser.cpp b/src/providers/openai/openai_response_parser.cpp index 0274956..9709706 100644 --- a/src/providers/openai/openai_response_parser.cpp +++ b/src/providers/openai/openai_response_parser.cpp @@ -145,7 +145,7 @@ EmbeddingResult OpenAIResponseParser::parse_success_embedding_response( // Extract choices if (response.contains("data") && !response["data"].empty()) { - result.data = std::move(response["data"]); + result.data = response["data"]; } // Extract usage @@ -192,4 +192,4 @@ FinishReason OpenAIResponseParser::parse_finish_reason( } } // namespace openai -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/openai/openai_stream.cpp b/src/providers/openai/openai_stream.cpp index bc82b13..6347b3a 100644 --- a/src/providers/openai/openai_stream.cpp +++ b/src/providers/openai/openai_stream.cpp @@ -1,282 +1,99 @@ #include "openai_stream.h" #include "ai/logger.h" -#include "http/http_request_handler.h" - -#include -#include - -namespace { -constexpr auto kEventTimeout = static_cast(30); -constexpr auto kSleepInterval = std::chrono::milliseconds(1); -constexpr auto kConnectionTimeout = 30; // seconds -constexpr auto kReadTimeout = 300; // 5 minutes for long generations -} // namespace namespace ai { namespace openai { OpenAIStreamImpl::~OpenAIStreamImpl() { + // Join before this class's state is destroyed; the stream thread calls + // process_sse_line. stop_stream(); } -void OpenAIStreamImpl::start_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body) { - ai::logger::log_debug("Starting OpenAI stream - URL: {}", url); - - std::lock_guard lock(thread_mutex_); - - if (stream_thread_.joinable()) { - ai::logger::log_debug( - "Stream thread already running, not starting new one"); - return; // Already running - } - - // Reset state for new stream - should_stop_ = false; - is_complete_ = false; - finish_event_pushed_ = false; - - ai::logger::log_info("Launching stream thread for OpenAI API"); - - stream_thread_ = std::thread([this, url, headers, request_body]() { - run_stream(url, headers, request_body); - }); -} - -StreamEvent OpenAIStreamImpl::get_next_event() { - StreamEvent event(""); - auto start_time = std::chrono::steady_clock::now(); - - while (!event_queue_.try_dequeue(event)) { - if (is_complete_ && event_queue_.size_approx() == 0) { - // Stream is complete and queue is empty - ai::logger::log_debug( - "Stream complete and queue empty, returning empty event"); - return StreamEvent(""); - } - - // Check for timeout - if (std::chrono::steady_clock::now() - start_time > kEventTimeout) { - ai::logger::log_error( - "Timeout waiting for next stream event after {} seconds", - kEventTimeout.count()); - return StreamEvent(kStreamEventTypeError, - "Timeout waiting for next event"); +void OpenAIStreamImpl::process_sse_line(std::string_view line) { + if (!line.starts_with("data: ")) { + if (!line.empty()) { + ai::logger::log_debug("Ignoring non-data SSE line: {}", line); } - - std::this_thread::sleep_for(kSleepInterval); + return; } - ai::logger::log_debug("Dequeued event type: {}", - static_cast(event.type)); - return event; -} - -bool OpenAIStreamImpl::has_more_events() const { - // No locks needed - these are atomic operations - return event_queue_.size_approx() > 0 || !is_complete_; -} - -void OpenAIStreamImpl::stop_stream() { - ai::logger::log_debug("Stopping OpenAI stream"); - - should_stop_ = true; // Atomic write - - std::lock_guard lock(thread_mutex_); - if (stream_thread_.joinable()) { - ai::logger::log_debug("Waiting for stream thread to finish"); - stream_thread_.join(); - ai::logger::log_info("OpenAI stream stopped successfully"); - } -} + auto data = line.substr(6); -void OpenAIStreamImpl::run_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body) { - // Extract host and path from URL - std::string_view url_view(url); + ai::logger::log_debug("Processing SSE line - data length: {}", data.length()); - // Skip protocol - if (auto pos = url_view.find("://"); pos != std::string_view::npos) { - url_view.remove_prefix(pos + 3); + if (data == "[DONE]") { + ai::logger::log_debug("Received [DONE] signal, stream ending"); + flush_all_pending_tool_calls(); + mark_complete(); + return; } - // Split host and path - auto slash_pos = url_view.find('/'); - std::string host(url_view.substr(0, slash_pos)); - std::string path = (slash_pos != std::string_view::npos) - ? std::string(url_view.substr(slash_pos)) - : "/v1/chat/completions"; - - ai::logger::log_debug( - "Stream thread started - connecting to {} with path: {}", host, path); - try { - httplib::SSLClient client(host); - client.enable_server_certificate_verification(true); - client.set_connection_timeout(kConnectionTimeout); - client.set_read_timeout(kReadTimeout); - - ai::logger::log_debug( - "SSL client created with connection_timeout: {}s, read_timeout: {}s", - kConnectionTimeout, kReadTimeout); - - std::string accumulated_data; - - // Create request - httplib::Request req; - req.method = "POST"; - req.path = path; - req.headers = headers; - req.body = request_body.dump(); - req.set_header("Content-Type", "application/json"); - - ai::logger::log_debug( - "Stream request prepared - path: {}, body size: {} bytes", path, - req.body.length()); - - // Set content receiver for streaming response - req.content_receiver = [this, &accumulated_data]( - const char* data, size_t data_length, - uint64_t /*offset*/, uint64_t /*total_length*/) { - // Accumulate data and process complete lines - accumulated_data.append(data, data_length); - - ai::logger::log_debug("Received {} bytes of stream data", data_length); - - // Process complete lines - size_t pos = 0; - while ((pos = accumulated_data.find('\n')) != std::string::npos) { - std::string line = accumulated_data.substr(0, pos); - accumulated_data.erase(0, pos + 1); - - if (!line.empty() && line.back() == '\r') { - line.pop_back(); - } + auto json = nlohmann::json::parse(data); + auto& choices = json["choices"]; + + if (!choices.empty() && choices[0].contains("delta")) { + auto& delta = choices[0]["delta"]; + if (delta.contains("content") && !delta["content"].is_null()) { + std::string content = delta["content"].get(); + ai::logger::log_debug("Received content chunk - length: {}", + content.length()); + push_event(StreamEvent(content)); + } - // Check if we should stop - atomic read, no lock needed - if (should_stop_) { - ai::logger::log_debug( - "Stream stop requested, ending content receiver"); - return false; + if (delta.contains("tool_calls") && delta["tool_calls"].is_array()) { + for (const auto& fragment : delta["tool_calls"]) { + const auto index = fragment.value("index", std::size_t{0}); + auto& pending = pending_tool_calls_[index]; + + if (fragment.contains("id") && fragment["id"].is_string()) { + pending.id = fragment["id"].get(); + } + if (fragment.contains("function") && + fragment["function"].is_object()) { + const auto& function = fragment["function"]; + if (function.contains("name") && function["name"].is_string()) { + pending.name = function["name"].get(); + } + if (function.contains("arguments") && + function["arguments"].is_string()) { + pending.arguments += function["arguments"].get(); + } + } } - - parse_sse_line(line); } - - return true; // Continue receiving - }; - - httplib::Response res; - httplib::Error error; - - ai::logger::log_info("Sending stream request to OpenAI API"); - - if (!client.send(req, res, error)) { - std::string error_msg = "Network error: " + httplib::to_string(error); - ai::logger::log_error("Failed to send stream request: {}", error_msg); - push_event(create_error_event(error_msg)); - } else if (res.status != 200) { - ai::logger::log_error("OpenAI stream API returned status {} - body: {}", - res.status, res.body); - push_event(create_error_event("HTTP " + std::to_string(res.status) + - " error: " + res.body)); - } else { - ai::logger::log_info("Stream completed successfully"); - } - } catch (const std::exception& e) { - ai::logger::log_error("Exception in stream thread: {}", e.what()); - push_event(create_error_event(e.what())); - } - - mark_complete(); - ai::logger::log_debug("Stream thread exiting"); -} - -void OpenAIStreamImpl::parse_sse_line(const std::string& line) { - if (line.starts_with("data: ")) { - auto data = line.substr(6); - - ai::logger::log_debug("Processing SSE line - data length: {}", - data.length()); - - if (data == "[DONE]") { - ai::logger::log_debug("Received [DONE] signal, stream ending"); - push_finish_event_if_needed(); - mark_complete(); - return; } - try { - auto json = nlohmann::json::parse(data); - auto& choices = json["choices"]; + // Check for finish_reason + if (!choices.empty() && choices[0].contains("finish_reason") && + !choices[0]["finish_reason"].is_null()) { + auto finish_reason_str = choices[0]["finish_reason"].get(); + finish_reason_ = parse_finish_reason(finish_reason_str); - if (!choices.empty() && choices[0].contains("delta")) { - auto& delta = choices[0]["delta"]; - if (delta.contains("content") && !delta["content"].is_null()) { - std::string content = delta["content"].get(); - ai::logger::log_debug("Received content chunk - length: {}", - content.length()); - push_event(StreamEvent(content)); - } + if (finish_reason_ == kFinishReasonToolCalls) { + flush_all_pending_tool_calls(); } - // Check for finish_reason - if (!choices.empty() && choices[0].contains("finish_reason") && - !choices[0]["finish_reason"].is_null()) { - auto finish_reason_str = choices[0]["finish_reason"].get(); - auto finish_reason = parse_finish_reason(finish_reason_str); - - ai::logger::log_debug("Stream finished with reason: {}", - finish_reason_str); - - finish_event_pushed_ = true; - - if (json.contains("usage")) { - auto usage = parse_usage(json["usage"]); - ai::logger::log_info( - "Stream completed - tokens used: {} prompt, {} completion, {} " - "total", - usage.prompt_tokens, usage.completion_tokens, usage.total_tokens); - push_event(StreamEvent(kStreamEventTypeFinish, usage, finish_reason)); - } else { - push_event(StreamEvent(kStreamEventTypeFinish)); - } - } - } catch (const std::exception& e) { - ai::logger::log_error("Failed to parse SSE line: {} - Line content: {}", - e.what(), data); + ai::logger::log_debug("Stream finished with reason: {}", + finish_reason_str); } - } else if (!line.empty()) { - ai::logger::log_debug("Ignoring non-data SSE line: {}", line); - } -} -void OpenAIStreamImpl::push_event(StreamEvent event) { - event_queue_.enqueue(std::move(event)); -} - -void OpenAIStreamImpl::push_finish_event_if_needed() { - bool expected = false; - if (finish_event_pushed_.compare_exchange_strong(expected, true)) { - ai::logger::log_debug("Pushing finish event to queue"); - event_queue_.enqueue(StreamEvent(kStreamEventTypeFinish)); - } else { - ai::logger::log_debug("Finish event already pushed, skipping"); + if (json.contains("usage") && !json["usage"].is_null()) { + usage_ = parse_usage(json["usage"]); + ai::logger::log_info( + "Stream completed - tokens used: {} prompt, {} completion, {} " + "total", + usage_->prompt_tokens, usage_->completion_tokens, + usage_->total_tokens); + } + } catch (const std::exception& e) { + ai::logger::log_error("Failed to parse SSE line: {} - Line content: {}", + e.what(), data); } } -void OpenAIStreamImpl::mark_complete() { - is_complete_ = true; // Atomic write -} - -StreamEvent OpenAIStreamImpl::create_error_event(const std::string& message) { - ai::logger::log_debug("Creating error event: {}", message); - return StreamEvent(kStreamEventTypeError, message); -} - FinishReason OpenAIStreamImpl::parse_finish_reason( const std::string& reason_str) { if (reason_str == "stop") { @@ -285,6 +102,8 @@ FinishReason OpenAIStreamImpl::parse_finish_reason( return kFinishReasonLength; } else if (reason_str == "content_filter") { return kFinishReasonContentFilter; + } else if (reason_str == "tool_calls") { + return kFinishReasonToolCalls; } return kFinishReasonStop; } diff --git a/src/providers/openai/openai_stream.h b/src/providers/openai/openai_stream.h index 4c15c91..d2b514a 100644 --- a/src/providers/openai/openai_stream.h +++ b/src/providers/openai/openai_stream.h @@ -1,58 +1,31 @@ #pragma once -#include "ai/types/stream_result.h" +#include "providers/http_sse_stream.h" -#include -#include -#include -#include -#include +#include +#include #include namespace ai { namespace openai { -class OpenAIStreamImpl : public internal::StreamResultImpl { +class OpenAIStreamImpl : public providers::streaming::HttpSseStream { public: - OpenAIStreamImpl() = default; - ~OpenAIStreamImpl(); - - // Non-copyable, non-movable for thread safety - OpenAIStreamImpl(const OpenAIStreamImpl&) = delete; - OpenAIStreamImpl& operator=(const OpenAIStreamImpl&) = delete; - OpenAIStreamImpl(OpenAIStreamImpl&&) = delete; - OpenAIStreamImpl& operator=(OpenAIStreamImpl&&) = delete; - - void start_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body); - - StreamEvent get_next_event() override; - bool has_more_events() const override; - void stop_stream() override; + OpenAIStreamImpl() + : HttpSseStream({.default_path = "/v1/chat/completions", + .connection_timeout_seconds = 30, + // 5 minutes for long generations + .read_timeout_seconds = 300}) {} + ~OpenAIStreamImpl() override; private: - void run_stream(const std::string& url, - const httplib::Headers& headers, - const nlohmann::json& request_body); - void parse_sse_line(const std::string& line); - void push_event(StreamEvent event); - void push_finish_event_if_needed(); - void mark_complete(); + void process_sse_line(std::string_view line) override; // Helper functions - StreamEvent create_error_event(const std::string& message); - FinishReason parse_finish_reason(const std::string& reason_str); - Usage parse_usage(const nlohmann::json& usage_json); - - moodycamel::ConcurrentQueue event_queue_; - std::thread stream_thread_; - std::mutex thread_mutex_; - std::atomic is_complete_{false}; - std::atomic should_stop_{false}; - std::atomic finish_event_pushed_{false}; + static FinishReason parse_finish_reason(const std::string& reason_str); + static Usage parse_usage(const nlohmann::json& usage_json); }; } // namespace openai -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/providers/stream_utils.h b/src/providers/stream_utils.h new file mode 100644 index 0000000..a798b80 --- /dev/null +++ b/src/providers/stream_utils.h @@ -0,0 +1,59 @@ +#pragma once + +#include "ai/types/stream_event.h" + +#include +#include +#include + +#include + +namespace ai { +namespace providers { +namespace streaming { + +/// One partially received tool call, accumulated across SSE fragments. +struct PendingToolCall { + std::string id; + std::string name; + std::string arguments; // Accumulated argument fragments (JSON text). + std::string initial_input; // Complete input object sent up front, if any. +}; + +/// Convert an accumulated tool call into a StreamEvent. Returns a tool-call +/// event on success or an error event when the accumulated arguments are not +/// valid JSON. +inline StreamEvent make_tool_call_event(const PendingToolCall& pending) { + const std::string& raw = + pending.arguments.empty() ? pending.initial_input : pending.arguments; + try { + auto arguments = + raw.empty() ? nlohmann::json::object() : nlohmann::json::parse(raw); + return StreamEvent( + ToolCall(pending.id, pending.name, std::move(arguments))); + } catch (const nlohmann::json::exception& e) { + return StreamEvent(kStreamEventTypeError, + "Failed to parse streamed tool-call arguments: " + + std::string(e.what())); + } +} + +/// Split a URL into origin ("scheme://host[:port]") and path, defaulting the +/// path when the URL has none. +inline std::pair split_origin_and_path( + std::string_view url, + std::string_view default_path) { + const auto scheme_pos = url.find("://"); + const auto authority_start = + scheme_pos == std::string_view::npos ? 0 : scheme_pos + 3; + const auto slash_pos = url.find('/', authority_start); + std::string origin(url.substr(0, slash_pos)); + std::string path = slash_pos == std::string_view::npos + ? std::string(default_path) + : std::string(url.substr(slash_pos)); + return {std::move(origin), std::move(path)}; +} + +} // namespace streaming +} // namespace providers +} // namespace ai diff --git a/src/tools/tool_executor.cpp b/src/tools/tool_executor.cpp index 29c1185..0d3e2cc 100644 --- a/src/tools/tool_executor.cpp +++ b/src/tools/tool_executor.cpp @@ -232,6 +232,7 @@ Tool create_simple_tool(const std::string& name, const std::string& description, const std::map& parameters, ToolExecuteFunction execute_func) { + (void)name; // The ToolSet key is the canonical tool name. JsonValue schema = create_object_schema(parameters); return create_tool(description, schema, std::move(execute_func)); } @@ -241,6 +242,7 @@ Tool create_simple_async_tool( const std::string& description, const std::map& parameters, AsyncToolExecuteFunction execute_func) { + (void)name; // The ToolSet key is the canonical tool name. JsonValue schema = create_object_schema(parameters); return create_async_tool(description, schema, std::move(execute_func)); } @@ -280,4 +282,4 @@ std::string generate_tool_call_id() { return id; } -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/src/utils/env_utils.h b/src/utils/env_utils.h new file mode 100644 index 0000000..29ce392 --- /dev/null +++ b/src/utils/env_utils.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +namespace ai { +namespace utils { + +/// Read an environment variable, treating unset and empty values as absent. +inline std::optional non_empty_env(const char* name) { + const char* value = std::getenv(name); + if (!value || *value == '\0') { + return std::nullopt; + } + return std::string(value); +} + +} // namespace utils +} // namespace ai diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4e7cadd..1178bf2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,7 +60,7 @@ target_compile_definitions(ai_tests PRIVATE $<$:AI_SDK_DEBUG_TESTS=1> # httplib configuration (must match main library) CPPHTTPLIB_OPENSSL_SUPPORT=1 - CPPHTTPLIB_BROTLI_SUPPORT=1 + CPPHTTPLIB_ZLIB_SUPPORT=1 CPPHTTPLIB_THREAD_POOL_COUNT=8 ) diff --git a/tests/integration/anthropic_integration_test.cpp b/tests/integration/anthropic_integration_test.cpp index 7010673..83bb9ab 100644 --- a/tests/integration/anthropic_integration_test.cpp +++ b/tests/integration/anthropic_integration_test.cpp @@ -23,7 +23,7 @@ class AnthropicIntegrationTest : public AITestFixture { // Check if we should run real API tests const char* api_key = std::getenv("ANTHROPIC_API_KEY"); - if (api_key != nullptr) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::anthropic::create_client(api_key); } else { @@ -278,7 +278,7 @@ TEST_F(AnthropicIntegrationTest, LargePromptHandling) { auto large_prompt = TestDataGenerator::createLargePrompt(2000); // ~2KB prompt - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, large_prompt); + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, large_prompt); auto result = client_->generate_text(options); TestAssertions::assertSuccess(result); @@ -299,7 +299,7 @@ TEST_F(AnthropicIntegrationTest, CustomBaseUrl) { auto custom_client = ai::anthropic::create_client(api_key, "https://api.anthropic.com"); - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, "Test custom base URL"); auto result = custom_client.generate_text(options); @@ -313,7 +313,7 @@ TEST_F(AnthropicIntegrationTest, EmptyPrompt) { GTEST_SKIP() << "No ANTHROPIC_API_KEY environment variable set"; } - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, ""); + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, ""); // Empty prompt should be caught by validation EXPECT_FALSE(options.is_valid()); @@ -328,7 +328,7 @@ TEST_F(AnthropicIntegrationTest, VeryLongResponse) { GTEST_SKIP() << "No ANTHROPIC_API_KEY environment variable set"; } - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, "Write a detailed explanation of quantum physics"); options.max_tokens = 500; // Reasonable limit for testing @@ -349,8 +349,7 @@ TEST_F(AnthropicIntegrationTest, NetworkTimeout) { // Note: This test documents timeout behavior but cannot reliably trigger it // with the real API under normal conditions - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, - "Simple test"); + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, "Simple test"); auto result = client_->generate_text(options); // Under normal conditions, this should succeed @@ -375,7 +374,7 @@ TEST_F(AnthropicIntegrationTest, NetworkFailure) { auto failing_client = ai::anthropic::create_client( api_key, "http://localhost:59999"); // Very unlikely port to be in use - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, "Test network failure"); auto result = failing_client.generate_text(options); @@ -410,7 +409,7 @@ TEST_F(AnthropicEnvironmentConfigTest, ConfigurationFromEnvironment) { const char* api_key = std::getenv("ANTHROPIC_API_KEY"); const char* base_url = std::getenv("ANTHROPIC_BASE_URL"); - if (api_key) { + if (api_key && *api_key != '\0') { auto client = base_url ? ai::anthropic::create_client(api_key, base_url) : ai::anthropic::create_client(api_key); EXPECT_TRUE(client.is_valid()); @@ -426,7 +425,7 @@ TEST_F(AnthropicIntegrationTest, MaxTokensRequired) { GTEST_SKIP() << "No ANTHROPIC_API_KEY environment variable set"; } - GenerateOptions options(ai::anthropic::models::kClaudeSonnet45, + GenerateOptions options(ai::anthropic::models::kClaudeSonnet5, "Tell me about artificial intelligence"); // Anthropic requires max_tokens to be set options.max_tokens = 100; @@ -445,7 +444,7 @@ TEST_F(AnthropicIntegrationTest, SystemMessageHandling) { // Anthropic has specific handling for system messages GenerateOptions options( - ai::anthropic::models::kClaudeSonnet45, + ai::anthropic::models::kClaudeSonnet5, "You are Claude, an AI assistant created by Anthropic.", "What is your name?"); options.max_tokens = 50; diff --git a/tests/integration/clickhouse_integration_test.cpp b/tests/integration/clickhouse_integration_test.cpp index a9dbfde..c32ff02 100644 --- a/tests/integration/clickhouse_integration_test.cpp +++ b/tests/integration/clickhouse_integration_test.cpp @@ -190,6 +190,27 @@ class ClickHouseTools { class ClickHouseIntegrationTest : public ::testing::TestWithParam { protected: void SetUp() override { + // Skip before connecting to ClickHouse when this provider is not + // configured. This keeps local/offline test runs from failing on an + // external service they cannot use anyway. + provider_type_ = GetParam(); + if (provider_type_ == "openai") { + const char* api_key = std::getenv("OPENAI_API_KEY"); + if (!api_key || *api_key == '\0') { + return; + } + client_ = std::make_shared(openai::create_client()); + model_ = openai::models::kDefaultModel; + } else if (provider_type_ == "anthropic") { + const char* api_key = std::getenv("ANTHROPIC_API_KEY"); + if (!api_key || *api_key == '\0') { + return; + } + client_ = std::make_shared(anthropic::create_client()); + model_ = anthropic::models::kDefaultModel; + } + use_real_api_ = true; + // Generate random suffix for table names to allow parallel test execution table_suffix_ = generateRandomSuffix(); // Use unique database name for each test to allow parallel execution @@ -215,27 +236,6 @@ class ClickHouseIntegrationTest : public ::testing::TestWithParam { {"list_tables_in_database", tools_helper_->createListTablesInDatabaseTool()}, {"get_schema_for_table", tools_helper_->createGetSchemaForTableTool()}}; - - // Initialize AI client based on provider - provider_type_ = GetParam(); - if (provider_type_ == "openai") { - const char* api_key = std::getenv("OPENAI_API_KEY"); - if (!api_key) { - use_real_api_ = false; - return; - } - client_ = std::make_shared(openai::create_client()); - model_ = openai::models::kGpt4o; - } else if (provider_type_ == "anthropic") { - const char* api_key = std::getenv("ANTHROPIC_API_KEY"); - if (!api_key) { - use_real_api_ = false; - return; - } - client_ = std::make_shared(anthropic::create_client()); - model_ = anthropic::models::kClaudeSonnet4; - } - use_real_api_ = true; } void TearDown() override { diff --git a/tests/integration/multi_step_duplicate_execution_test.cpp b/tests/integration/multi_step_duplicate_execution_test.cpp index 7df0029..dd44fa5 100644 --- a/tests/integration/multi_step_duplicate_execution_test.cpp +++ b/tests/integration/multi_step_duplicate_execution_test.cpp @@ -29,19 +29,19 @@ class MultiStepDuplicateExecutionTest if (provider == "openai") { const char* api_key = std::getenv("OPENAI_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::openai::create_client(api_key); - model_ = ai::openai::models::kGpt4oMini; + model_ = ai::openai::models::kGpt56Luna; } else { use_real_api_ = false; } } else if (provider == "anthropic") { const char* api_key = std::getenv("ANTHROPIC_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::anthropic::create_client(api_key); - model_ = ai::anthropic::models::kClaudeSonnet45; + model_ = ai::anthropic::models::kClaudeSonnet5; } else { use_real_api_ = false; } diff --git a/tests/integration/openai_embeddings_integration_test.cpp b/tests/integration/openai_embeddings_integration_test.cpp index 7244ffc..6fce006 100644 --- a/tests/integration/openai_embeddings_integration_test.cpp +++ b/tests/integration/openai_embeddings_integration_test.cpp @@ -22,7 +22,7 @@ class OpenAIEmbeddingsIntegrationTest : public AITestFixture { // Check if we should run real API tests const char* api_key = std::getenv("OPENAI_API_KEY"); - if (api_key != nullptr) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::openai::create_client(api_key); } else { @@ -354,7 +354,7 @@ TEST_F(OpenAIEmbeddingsIntegrationTest, TokenUsageTracking) { // Network Error Tests TEST_F(OpenAIEmbeddingsIntegrationTest, NetworkFailure) { const char* api_key = std::getenv("OPENAI_API_KEY"); - if (!api_key) { + if (!api_key || *api_key == '\0') { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } diff --git a/tests/integration/openai_integration_test.cpp b/tests/integration/openai_integration_test.cpp index 3f7fae7..cbabff3 100644 --- a/tests/integration/openai_integration_test.cpp +++ b/tests/integration/openai_integration_test.cpp @@ -23,7 +23,7 @@ class OpenAIIntegrationTest : public AITestFixture { // Check if we should run real API tests const char* api_key = std::getenv("OPENAI_API_KEY"); - if (api_key != nullptr) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::openai::create_client(api_key); } else { @@ -44,7 +44,7 @@ TEST_F(OpenAIIntegrationTest, BasicTextGeneration) { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, "Hello, how are you?"); auto result = client_->generate_text(options); @@ -63,7 +63,7 @@ TEST_F(OpenAIIntegrationTest, TextGenerationWithSystemPrompt) { } GenerateOptions options( - ai::openai::models::kGpt4oMini, + ai::openai::models::kGpt56Luna, "You are a helpful assistant that responds in French.", "Hello, how are you?"); @@ -85,7 +85,7 @@ TEST_F(OpenAIIntegrationTest, TextGenerationWithParameters) { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, "Write a very short story about a cat."); options.max_tokens = 50; options.temperature = 0.7; @@ -112,7 +112,7 @@ TEST_F(OpenAIIntegrationTest, ConversationWithMessages) { Message::assistant("Hello! I can help you with weather information."), Message::user("What's the weather like today?")}; - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, std::move(conversation)); auto result = client_->generate_text(options); @@ -133,8 +133,8 @@ TEST_F(OpenAIIntegrationTest, DifferentModelSupport) { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } - std::vector models_to_test = {ai::openai::models::kGpt4o, - ai::openai::models::kGpt4oMini}; + std::vector models_to_test = {ai::openai::models::kGpt56, + ai::openai::models::kGpt56Luna}; for (const auto& model : models_to_test) { if (!client_->supports_model(model)) { @@ -160,7 +160,7 @@ TEST_F(OpenAIIntegrationTest, InvalidApiKey) { // Test with invalid API key auto invalid_client = ai::openai::create_client("sk-invalid123"); - GenerateOptions options(ai::openai::models::kGpt4oMini, "Test prompt"); + GenerateOptions options(ai::openai::models::kGpt56Luna, "Test prompt"); auto result = invalid_client.generate_text(options); TestAssertions::assertError(result); @@ -194,7 +194,7 @@ TEST_F(OpenAIIntegrationTest, RateLimitHandling) { // Note: This test may not trigger rate limiting in normal usage // It's here to document the expected behavior when rate limits are hit - GenerateOptions options(ai::openai::models::kGpt4oMini, "Test prompt"); + GenerateOptions options(ai::openai::models::kGpt56Luna, "Test prompt"); auto result = client_->generate_text(options); // If we hit rate limits, the error should be handled gracefully @@ -215,7 +215,7 @@ TEST_F(OpenAIIntegrationTest, BasicStreaming) { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } - GenerateOptions gen_options(ai::openai::models::kGpt4oMini, + GenerateOptions gen_options(ai::openai::models::kGpt56Luna, "Count from 1 to 3"); StreamOptions options(gen_options); auto stream = client_->stream_text(options); @@ -254,7 +254,7 @@ TEST_F(OpenAIIntegrationTest, ConcurrentRequests) { for (int i = 0; i < num_requests; ++i) { futures.push_back(std::async(std::launch::async, [this, i]() { - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, "Say hello " + std::to_string(i)); return client_->generate_text(options); })); @@ -277,7 +277,7 @@ TEST_F(OpenAIIntegrationTest, LargePromptHandling) { auto large_prompt = TestDataGenerator::createLargePrompt(2000); // ~2KB prompt - GenerateOptions options(ai::openai::models::kGpt4oMini, large_prompt); + GenerateOptions options(ai::openai::models::kGpt56Luna, large_prompt); auto result = client_->generate_text(options); TestAssertions::assertSuccess(result); @@ -299,7 +299,7 @@ TEST_F(OpenAIIntegrationTest, CustomBaseUrl) { auto custom_client = ai::openai::create_client(api_key, "https://api.openai.com"); - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, "Test custom base URL"); auto result = custom_client.generate_text(options); @@ -313,7 +313,7 @@ TEST_F(OpenAIIntegrationTest, EmptyPrompt) { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } - GenerateOptions options(ai::openai::models::kGpt4oMini, ""); + GenerateOptions options(ai::openai::models::kGpt56Luna, ""); // Empty prompt should be caught by validation EXPECT_FALSE(options.is_valid()); @@ -328,7 +328,7 @@ TEST_F(OpenAIIntegrationTest, VeryLongResponse) { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, "Write a detailed explanation of quantum physics"); options.max_tokens = 500; // Reasonable limit for testing @@ -349,7 +349,7 @@ TEST_F(OpenAIIntegrationTest, NetworkTimeout) { // Note: This test documents timeout behavior but cannot reliably trigger it // with the real API under normal conditions - GenerateOptions options(ai::openai::models::kGpt4oMini, "Simple test"); + GenerateOptions options(ai::openai::models::kGpt56Luna, "Simple test"); auto result = client_->generate_text(options); // Under normal conditions, this should succeed @@ -374,7 +374,7 @@ TEST_F(OpenAIIntegrationTest, NetworkFailure) { auto failing_client = ai::openai::create_client( api_key, "http://localhost:59999"); // Very unlikely port to be in use - GenerateOptions options(ai::openai::models::kGpt4oMini, + GenerateOptions options(ai::openai::models::kGpt56Luna, "Test network failure"); auto result = failing_client.generate_text(options); @@ -408,7 +408,7 @@ TEST_F(EnvironmentConfigTest, ConfigurationFromEnvironment) { const char* api_key = std::getenv("OPENAI_API_KEY"); const char* base_url = std::getenv("OPENAI_BASE_URL"); - if (api_key) { + if (api_key && *api_key != '\0') { auto client = base_url ? ai::openai::create_client(api_key, base_url) : ai::openai::create_client(api_key); EXPECT_TRUE(client.is_valid()); @@ -421,7 +421,7 @@ TEST_F(EnvironmentConfigTest, ConfigurationFromEnvironment) { TEST_F(EnvironmentConfigTest, DefaultClientCreation) { const char* api_key = std::getenv("OPENAI_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { // Test creating client with default configuration (reads from environment) auto client = ai::openai::create_client(); EXPECT_TRUE(client.is_valid()); @@ -429,8 +429,9 @@ TEST_F(EnvironmentConfigTest, DefaultClientCreation) { // Test that supported models include expected ones auto models = client.supported_models(); - EXPECT_TRUE(client.supports_model(ai::openai::models::kGpt4oMini)); - EXPECT_TRUE(client.supports_model(ai::openai::models::kGpt35Turbo)); + EXPECT_TRUE(client.supports_model(ai::openai::models::kGpt56Luna)); + EXPECT_TRUE(client.supports_model(ai::openai::models::kGpt41)); + EXPECT_FALSE(client.supports_model("gpt-3.5-turbo")); } else { GTEST_SKIP() << "No OPENAI_API_KEY environment variable set"; } @@ -492,4 +493,4 @@ TEST_F(OpenAIIntegrationTest, DefaultModelStreaming) { } } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/integration/tool_calling_integration_test.cpp b/tests/integration/tool_calling_integration_test.cpp index 415cc98..0a739ca 100644 --- a/tests/integration/tool_calling_integration_test.cpp +++ b/tests/integration/tool_calling_integration_test.cpp @@ -134,19 +134,19 @@ class ToolCallingIntegrationTest if (provider == "openai") { const char* api_key = std::getenv("OPENAI_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::openai::create_client(api_key); - model_ = ai::openai::models::kGpt4oMini; + model_ = ai::openai::models::kGpt56Luna; } else { use_real_api_ = false; } } else if (provider == "anthropic") { const char* api_key = std::getenv("ANTHROPIC_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::anthropic::create_client(api_key); - model_ = ai::anthropic::models::kClaudeSonnet45; + model_ = ai::anthropic::models::kClaudeSonnet5; } else { use_real_api_ = false; } @@ -385,6 +385,7 @@ TEST_P(ToolCallingIntegrationTest, ToolExecutionResults) { GenerateOptions options( model_, "What's 15 + 25? Please show me the exact calculation."); options.tools = tools_; + options.tool_choice = ToolChoice::specific("calculator"); options.max_tokens = 300; auto result = client_->generate_text(options); @@ -630,10 +631,10 @@ class OpenAISpecificToolTest : public ::testing::Test { protected: void SetUp() override { const char* api_key = std::getenv("OPENAI_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::openai::create_client(api_key); - model_ = ai::openai::models::kGpt4oMini; + model_ = ai::openai::models::kGpt56Luna; } else { use_real_api_ = false; } @@ -669,10 +670,10 @@ class AnthropicSpecificToolTest : public ::testing::Test { protected: void SetUp() override { const char* api_key = std::getenv("ANTHROPIC_API_KEY"); - if (api_key) { + if (api_key && *api_key != '\0') { use_real_api_ = true; client_ = ai::anthropic::create_client(api_key); - model_ = ai::anthropic::models::kClaudeSonnet45; + model_ = ai::anthropic::models::kClaudeSonnet5; } else { use_real_api_ = false; } @@ -709,4 +710,4 @@ TEST_F(AnthropicSpecificToolTest, MaxTokensRequiredWithTools) { } } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/unit/anthropic_client_test.cpp b/tests/unit/anthropic_client_test.cpp index c98d451..aeab3da 100644 --- a/tests/unit/anthropic_client_test.cpp +++ b/tests/unit/anthropic_client_test.cpp @@ -2,11 +2,13 @@ #include // Include the Anthropic client headers +#include "ai/anthropic.h" #include "ai/types/generate_options.h" #include "ai/types/stream_options.h" // Include the real Anthropic client implementation for testing #include "providers/anthropic/anthropic_client.h" +#include "providers/anthropic/anthropic_request_builder.h" // Test utilities #include "../utils/test_fixtures.h" @@ -77,20 +79,35 @@ TEST_F(AnthropicClientTest, ConstructorWithHttpUrl) { // EXPECT_FALSE(client.get_use_ssl()); } +TEST_F(AnthropicClientTest, ConstructorWithCustomRetryConfig) { + retry::RetryConfig retry_config; + retry_config.max_retries = 5; + + ai::anthropic::AnthropicClient client( + "sk-ant-test", "https://api.anthropic.com", retry_config); + + EXPECT_TRUE(client.is_valid()); + EXPECT_EQ(client.get_base_url(), "https://api.anthropic.com"); +} + // Model Support Tests TEST_F(AnthropicClientTest, SupportedModelsContainsExpectedModels) { auto models = client_->supported_models(); - EXPECT_THAT(models, testing::Contains("claude-opus-4-7")); - EXPECT_THAT(models, testing::Contains("claude-sonnet-4-6")); + EXPECT_THAT(models, testing::Contains(anthropic::models::kClaudeFable5)); + EXPECT_THAT(models, testing::Contains(anthropic::models::kClaudeOpus5)); + EXPECT_THAT(models, testing::Contains(anthropic::models::kClaudeOpus48)); + EXPECT_THAT(models, testing::Contains(anthropic::models::kClaudeSonnet5)); EXPECT_THAT(models, testing::Contains("claude-haiku-4-5-20251001")); EXPECT_THAT(models, testing::Contains("claude-sonnet-4-5-20250929")); EXPECT_FALSE(models.empty()); } TEST_F(AnthropicClientTest, SupportsValidModel) { - EXPECT_TRUE(client_->supports_model("claude-opus-4-7")); - EXPECT_TRUE(client_->supports_model("claude-sonnet-4-6")); + EXPECT_TRUE(client_->supports_model(anthropic::models::kClaudeFable5)); + EXPECT_TRUE(client_->supports_model(anthropic::models::kClaudeOpus5)); + EXPECT_TRUE(client_->supports_model(anthropic::models::kClaudeOpus48)); + EXPECT_TRUE(client_->supports_model(anthropic::models::kClaudeSonnet5)); EXPECT_TRUE(client_->supports_model("claude-haiku-4-5-20251001")); } @@ -150,6 +167,22 @@ TEST_F(AnthropicClientTest, ValidateOptionsValidation) { EXPECT_TRUE(valid_options.is_valid()); } +TEST_F(AnthropicClientTest, RecentModelsOmitUnsupportedSamplingParameters) { + anthropic::AnthropicRequestBuilder builder; + for (const auto* model : + {anthropic::models::kClaudeOpus48, anthropic::models::kClaudeOpus47, + anthropic::models::kClaudeFable5, anthropic::models::kClaudeSonnet5}) { + GenerateOptions options(model, "Hello"); + options.temperature = 0.7; + options.top_p = 0.8; + + const auto request = builder.build_request_json(options); + + EXPECT_FALSE(request.contains("temperature")) << model; + EXPECT_FALSE(request.contains("top_p")) << model; + } +} + // Stream Tests (Basic validation) TEST_F(AnthropicClientTest, StreamTextBasicValidation) { auto options = @@ -205,4 +238,4 @@ TEST_F(AnthropicClientTest, TestStopReasonParsing) { */ } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/unit/anthropic_stream_test.cpp b/tests/unit/anthropic_stream_test.cpp index 518eeef..ded11ba 100644 --- a/tests/unit/anthropic_stream_test.cpp +++ b/tests/unit/anthropic_stream_test.cpp @@ -1,3 +1,5 @@ +#include "providers/anthropic/anthropic_stream.h" + #include "../utils/mock_anthropic_client.h" #include "../utils/test_fixtures.h" #include "ai/types/stream_event.h" @@ -98,6 +100,64 @@ TEST_F(AnthropicStreamEventTest, ParseErrorEvent) { EXPECT_THAT(error_data, testing::HasSubstr("invalid_request_error")); } +TEST_F(AnthropicStreamEventTest, ParsesFragmentedResponseWithUsage) { + anthropic::AnthropicStreamImpl stream; + stream.process_sse_chunk_for_testing( + "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_" + "tokens\":12}}}\n\n" + "data: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"Hel"); + stream.process_sse_chunk_for_testing( + "lo\"}}\n\n" + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_" + "tokens\"},\"usage\":{\"output_tokens\":7}}\n\n" + "data: {\"type\":\"message_stop\"}\n\n"); + + auto text_event = stream.get_next_event(); + ASSERT_TRUE(text_event.is_text_delta()); + EXPECT_EQ(text_event.text_delta, "Hello"); + + auto finish_event = stream.get_next_event(); + ASSERT_TRUE(finish_event.is_finish()); + ASSERT_TRUE(finish_event.usage.has_value()); + EXPECT_EQ(finish_event.usage->prompt_tokens, 12); + EXPECT_EQ(finish_event.usage->completion_tokens, 7); + EXPECT_EQ(finish_event.usage->total_tokens, 19); + EXPECT_EQ(finish_event.finish_reason, kFinishReasonLength); +} + +TEST_F(AnthropicStreamEventTest, EmitsOnlyOneFinishEvent) { + anthropic::AnthropicStreamImpl stream; + stream.process_sse_chunk_for_testing( + "data: {\"type\":\"message_stop\"}\n\n" + "data: {\"type\":\"message_stop\"}\n\n"); + + EXPECT_EQ(stream.queued_event_count_for_testing(), 1); + EXPECT_TRUE(stream.get_next_event().is_finish()); +} + +TEST_F(AnthropicStreamEventTest, ParsesFragmentedToolCall) { + anthropic::AnthropicStreamImpl stream; + stream.process_sse_chunk_for_testing( + R"(data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_123","name":"get_weather","input":{}}} + +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"loc"}} + +)"); + stream.process_sse_chunk_for_testing( + R"(data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ation\":\"Chicago\"}"}} + +data: {"type":"content_block_stop","index":1} + +)"); + + auto event = stream.get_next_event(); + ASSERT_TRUE(event.is_tool_call()); + ASSERT_TRUE(event.tool_call.has_value()); + EXPECT_EQ(event.tool_call->id, "tool_123"); + EXPECT_EQ(event.tool_call->tool_name, "get_weather"); + EXPECT_EQ(event.tool_call->arguments["location"], "Chicago"); +} + // Mock Stream Implementation Tests class MockAnthropicStreamImpl { public: @@ -391,4 +451,4 @@ TEST_F(AnthropicStreamEventTypesTest, MessageStopEvent) { } } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/unit/openai_client_test.cpp b/tests/unit/openai_client_test.cpp index 5646fd3..891f239 100644 --- a/tests/unit/openai_client_test.cpp +++ b/tests/unit/openai_client_test.cpp @@ -2,11 +2,13 @@ #include // Include the OpenAI client headers +#include "ai/openai.h" #include "ai/types/generate_options.h" #include "ai/types/stream_options.h" // Include the real OpenAI client implementation for testing #include "providers/openai/openai_client.h" +#include "providers/openai/openai_request_builder.h" // Test utilities #include "../utils/test_fixtures.h" @@ -79,14 +81,17 @@ TEST_F(OpenAIClientTest, ConstructorWithHttpUrl) { TEST_F(OpenAIClientTest, SupportedModelsContainsExpectedModels) { auto models = client_->supported_models(); - EXPECT_THAT(models, testing::Contains("gpt-5.4")); + EXPECT_THAT(models, testing::Contains(openai::models::kGpt56)); + EXPECT_THAT(models, testing::Contains(openai::models::kGpt56Sol)); + EXPECT_THAT(models, testing::Contains(openai::models::kGpt56Terra)); + EXPECT_THAT(models, testing::Contains(openai::models::kGpt56Luna)); EXPECT_THAT(models, testing::Contains("gpt-5-mini")); EXPECT_THAT(models, testing::Contains("gpt-4.1")); EXPECT_FALSE(models.empty()); } TEST_F(OpenAIClientTest, SupportsValidModel) { - EXPECT_TRUE(client_->supports_model("gpt-5.4")); + EXPECT_TRUE(client_->supports_model(openai::models::kGpt56)); EXPECT_TRUE(client_->supports_model("gpt-4.1")); } @@ -145,6 +150,15 @@ TEST_F(OpenAIClientTest, ValidateOptionsValidation) { EXPECT_TRUE(valid_options.is_valid()); } +TEST_F(OpenAIClientTest, Gpt56UsesChatCompletionsCompatibleReasoning) { + openai::OpenAIRequestBuilder builder; + GenerateOptions options(openai::models::kGpt56Luna, "Use a tool"); + + const auto request = builder.build_request_json(options); + + EXPECT_EQ(request["reasoning_effort"], "none"); +} + // Stream Tests (Basic validation) TEST_F(OpenAIClientTest, StreamTextBasicValidation) { auto options = StreamOptions(GenerateOptions(kTestModel, kTestPrompt)); @@ -197,4 +211,4 @@ TEST_F(OpenAIClientTest, TestFinishReasonParsing) { */ } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/unit/openai_stream_test.cpp b/tests/unit/openai_stream_test.cpp index 616bfa1..c16ca16 100644 --- a/tests/unit/openai_stream_test.cpp +++ b/tests/unit/openai_stream_test.cpp @@ -1,3 +1,5 @@ +#include "providers/openai/openai_stream.h" + #include "../utils/mock_openai_client.h" #include "../utils/test_fixtures.h" #include "ai/types/stream_event.h" @@ -14,9 +16,9 @@ class OpenAIStreamTest : public OpenAITestFixture {}; // StreamOptions Tests TEST_F(OpenAIStreamTest, StreamOptionsBasicConstructor) { - StreamOptions options(GenerateOptions("gpt-4o", "Hello, world!")); + StreamOptions options(GenerateOptions("test-model", "Hello, world!")); - EXPECT_EQ(options.model, "gpt-4o"); + EXPECT_EQ(options.model, "test-model"); EXPECT_EQ(options.prompt, "Hello, world!"); EXPECT_TRUE(options.system.empty()); EXPECT_TRUE(options.messages.empty()); @@ -24,30 +26,30 @@ TEST_F(OpenAIStreamTest, StreamOptionsBasicConstructor) { TEST_F(OpenAIStreamTest, StreamOptionsWithSystemPrompt) { StreamOptions options( - GenerateOptions("gpt-4o", "System prompt", "User prompt")); + GenerateOptions("test-model", "System prompt", "User prompt")); - EXPECT_EQ(options.model, "gpt-4o"); + EXPECT_EQ(options.model, "test-model"); EXPECT_EQ(options.system, "System prompt"); EXPECT_EQ(options.prompt, "User prompt"); } TEST_F(OpenAIStreamTest, StreamOptionsWithMessages) { Messages messages = createSampleConversation(); - StreamOptions options(GenerateOptions("gpt-4o", std::move(messages))); + StreamOptions options(GenerateOptions("test-model", std::move(messages))); - EXPECT_EQ(options.model, "gpt-4o"); + EXPECT_EQ(options.model, "test-model"); EXPECT_FALSE(options.messages.empty()); EXPECT_TRUE(options.has_messages()); } TEST_F(OpenAIStreamTest, StreamOptionsValidation) { - StreamOptions valid_options(GenerateOptions("gpt-4o", "Valid prompt")); + StreamOptions valid_options(GenerateOptions("test-model", "Valid prompt")); EXPECT_TRUE(valid_options.is_valid()); StreamOptions invalid_model(GenerateOptions("", "Valid prompt")); EXPECT_FALSE(invalid_model.is_valid()); - StreamOptions invalid_prompt(GenerateOptions("gpt-4o", "")); + StreamOptions invalid_prompt(GenerateOptions("test-model", "")); EXPECT_FALSE(invalid_prompt.is_valid()); } @@ -90,6 +92,43 @@ TEST_F(StreamEventTest, ParseErrorEvent) { EXPECT_THAT(error_data, testing::HasSubstr("Stream error")); } +TEST_F(StreamEventTest, ParsesFragmentedToolCall) { + openai::OpenAIStreamImpl stream; + stream.process_sse_line_for_testing( + R"(data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"get_weather","arguments":"{\"loc"}}]}}]})"); + stream.process_sse_line_for_testing( + R"(data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ation\":\"Chicago\"}"}}]},"finish_reason":"tool_calls"}]})"); + stream.process_sse_line_for_testing("data: [DONE]"); + + auto tool_event = stream.get_next_event(); + ASSERT_TRUE(tool_event.is_tool_call()); + ASSERT_TRUE(tool_event.tool_call.has_value()); + EXPECT_EQ(tool_event.tool_call->id, "call_123"); + EXPECT_EQ(tool_event.tool_call->tool_name, "get_weather"); + EXPECT_EQ(tool_event.tool_call->arguments["location"], "Chicago"); + + auto finish_event = stream.get_next_event(); + ASSERT_TRUE(finish_event.is_finish()); + ASSERT_TRUE(finish_event.finish_reason.has_value()); + EXPECT_EQ(*finish_event.finish_reason, kFinishReasonToolCalls); + EXPECT_FALSE(finish_event.usage.has_value()); +} + +TEST_F(StreamEventTest, EmitsOneFinishWithTerminalUsage) { + openai::OpenAIStreamImpl stream; + stream.process_sse_line_for_testing( + R"(data: {"choices":[{"delta":{},"finish_reason":"stop"}]})"); + stream.process_sse_line_for_testing( + R"(data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}})"); + stream.process_sse_line_for_testing("data: [DONE]"); + + ASSERT_EQ(stream.queued_event_count_for_testing(), 1); + auto finish_event = stream.get_next_event(); + ASSERT_TRUE(finish_event.is_finish()); + ASSERT_TRUE(finish_event.usage.has_value()); + EXPECT_EQ(finish_event.usage->total_tokens, 7); +} + // Mock Stream Implementation Tests class MockStreamImpl { public: @@ -180,7 +219,7 @@ class StreamErrorTest : public OpenAITestFixture {}; TEST_F(StreamErrorTest, HandleStreamConnectionError) { ControllableOpenAIClient client(kTestApiKey); - StreamOptions options(GenerateOptions("gpt-4o", "Test prompt")); + StreamOptions options(GenerateOptions("test-model", "Test prompt")); client.setShouldFail(true); @@ -193,7 +232,7 @@ TEST_F(StreamErrorTest, HandleStreamConnectionError) { TEST_F(StreamErrorTest, HandleStreamTimeout) { ControllableOpenAIClient client(kTestApiKey); - StreamOptions options(GenerateOptions("gpt-4o", "Test prompt")); + StreamOptions options(GenerateOptions("test-model", "Test prompt")); client.setShouldTimeout(true); @@ -273,13 +312,13 @@ TEST_F(StreamIntegrationTest, StreamWithClientConfiguration) { // Verify client supports streaming EXPECT_TRUE(client.is_valid()); - StreamOptions options(GenerateOptions("gpt-4o", "Stream test")); + StreamOptions options(GenerateOptions("test-model", "Stream test")); auto result = client.stream_text(options); EXPECT_EQ(client.getCallCount(), 1); auto last_options = client.getLastStreamOptions(); - EXPECT_EQ(last_options.model, "gpt-4o"); + EXPECT_EQ(last_options.model, "test-model"); EXPECT_EQ(last_options.prompt, "Stream test"); } @@ -328,4 +367,4 @@ TEST_F(StreamEdgeCaseTest, UnicodeInStream) { } } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/unit/types_test.cpp b/tests/unit/types_test.cpp index 455ec19..62d4c79 100644 --- a/tests/unit/types_test.cpp +++ b/tests/unit/types_test.cpp @@ -27,9 +27,9 @@ TEST_F(GenerateOptionsTest, DefaultConstructor) { } TEST_F(GenerateOptionsTest, ConstructorWithModelAndPrompt) { - GenerateOptions options("gpt-4o", "Hello, world!"); + GenerateOptions options("test-model", "Hello, world!"); - EXPECT_EQ(options.model, "gpt-4o"); + EXPECT_EQ(options.model, "test-model"); EXPECT_EQ(options.prompt, "Hello, world!"); EXPECT_TRUE(options.system.empty()); EXPECT_TRUE(options.messages.empty()); @@ -37,9 +37,9 @@ TEST_F(GenerateOptionsTest, ConstructorWithModelAndPrompt) { } TEST_F(GenerateOptionsTest, ConstructorWithSystemPrompt) { - GenerateOptions options("gpt-4o", "You are helpful", "Hello!"); + GenerateOptions options("test-model", "You are helpful", "Hello!"); - EXPECT_EQ(options.model, "gpt-4o"); + EXPECT_EQ(options.model, "test-model"); EXPECT_EQ(options.system, "You are helpful"); EXPECT_EQ(options.prompt, "Hello!"); EXPECT_TRUE(options.is_valid()); @@ -47,9 +47,9 @@ TEST_F(GenerateOptionsTest, ConstructorWithSystemPrompt) { TEST_F(GenerateOptionsTest, ConstructorWithMessages) { Messages messages = {Message::user("Hello"), Message::assistant("Hi there!")}; - GenerateOptions options("gpt-4o", std::move(messages)); + GenerateOptions options("test-model", std::move(messages)); - EXPECT_EQ(options.model, "gpt-4o"); + EXPECT_EQ(options.model, "test-model"); EXPECT_TRUE(options.prompt.empty()); EXPECT_EQ(options.messages.size(), 2); EXPECT_TRUE(options.has_messages()); @@ -63,20 +63,20 @@ TEST_F(GenerateOptionsTest, ValidationEmptyModel) { } TEST_F(GenerateOptionsTest, ValidationEmptyPromptAndMessages) { - GenerateOptions options("gpt-4o", ""); + GenerateOptions options("test-model", ""); EXPECT_FALSE(options.is_valid()); } TEST_F(GenerateOptionsTest, ValidationWithValidMessages) { Messages messages = {Message::user("Hello")}; - GenerateOptions options("gpt-4o", std::move(messages)); + GenerateOptions options("test-model", std::move(messages)); EXPECT_TRUE(options.is_valid()); } TEST_F(GenerateOptionsTest, OptionalParametersSet) { - GenerateOptions options("gpt-4o", "Test"); + GenerateOptions options("test-model", "Test"); options.temperature = 0.7; options.max_tokens = 100; @@ -152,13 +152,13 @@ TEST_F(GenerateResultTest, MetadataFields) { GenerateResult result("Text", kFinishReasonStop, Usage{}); result.id = "test-id-123"; - result.model = "gpt-4o"; + result.model = "test-model"; result.created = 1234567890; result.system_fingerprint = "fp_123"; result.provider_metadata = "{\"test\": true}"; EXPECT_EQ(result.id.value(), "test-id-123"); - EXPECT_EQ(result.model.value(), "gpt-4o"); + EXPECT_EQ(result.model.value(), "test-model"); EXPECT_EQ(result.created.value(), 1234567890); EXPECT_EQ(result.system_fingerprint.value(), "fp_123"); EXPECT_EQ(result.provider_metadata.value(), "{\"test\": true}"); @@ -318,7 +318,7 @@ class TypesEdgeCaseTest : public AITestFixture, TEST_P(TypesEdgeCaseTest, GenerateOptionsWithVariousPrompts) { std::string prompt = GetParam(); - GenerateOptions options("gpt-4o", prompt); + GenerateOptions options("test-model", prompt); if (prompt.empty()) { EXPECT_FALSE(options.is_valid()); diff --git a/tests/utils/mock_anthropic_client.cpp b/tests/utils/mock_anthropic_client.cpp index fb014b3..7ee7f6b 100644 --- a/tests/utils/mock_anthropic_client.cpp +++ b/tests/utils/mock_anthropic_client.cpp @@ -1,5 +1,6 @@ #include "mock_anthropic_client.h" +#include "ai/anthropic.h" #include "ai/types/enums.h" #include "ai/types/stream_result.h" @@ -132,8 +133,15 @@ std::string ControllableAnthropicClient::provider_name() const { } std::vector ControllableAnthropicClient::supported_models() const { - return {"claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5-20251001", - "claude-sonnet-4-5-20250929", "claude-opus-4-1-20250805"}; + return {anthropic::models::kClaudeFable5, + anthropic::models::kClaudeOpus5, + anthropic::models::kClaudeOpus48, + anthropic::models::kClaudeSonnet5, + anthropic::models::kClaudeOpus47, + anthropic::models::kClaudeSonnet46, + anthropic::models::kClaudeHaiku45Snapshot, + anthropic::models::kClaudeSonnet45Snapshot, + anthropic::models::kClaudeOpus41Snapshot}; } bool ControllableAnthropicClient::supports_model( @@ -273,4 +281,4 @@ std::string AnthropicResponseBuilder::buildMaxTokensRequiredResponse() { } } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/utils/mock_openai_client.cpp b/tests/utils/mock_openai_client.cpp index 00e47e9..b09f632 100644 --- a/tests/utils/mock_openai_client.cpp +++ b/tests/utils/mock_openai_client.cpp @@ -1,5 +1,6 @@ #include "mock_openai_client.h" +#include "ai/openai.h" #include "ai/types/enums.h" #include "ai/types/stream_result.h" @@ -112,7 +113,10 @@ std::string ControllableOpenAIClient::provider_name() const { } std::vector ControllableOpenAIClient::supported_models() const { - return {"gpt-5.4", "gpt-5-mini", "gpt-4.1"}; + return {openai::models::kGpt56, openai::models::kGpt56Sol, + openai::models::kGpt56Terra, openai::models::kGpt56Luna, + openai::models::kGpt55, openai::models::kGpt54, + openai::models::kGpt5Mini, openai::models::kGpt41}; } bool ControllableOpenAIClient::supports_model( @@ -215,7 +219,7 @@ std::vector ResponseBuilder::buildStreamingResponse( {"id", "chatcmpl-stream123"}, {"object", "chat.completion.chunk"}, {"created", 1234567890}, - {"model", "gpt-4o"}, + {"model", "test-model"}, {"choices", nlohmann::json::array({{{"index", 0}, {"delta", {{"content", word + " "}}}, @@ -228,7 +232,7 @@ std::vector ResponseBuilder::buildStreamingResponse( {"id", "chatcmpl-stream123"}, {"object", "chat.completion.chunk"}, {"created", 1234567890}, - {"model", "gpt-4o"}, + {"model", "test-model"}, {"choices", nlohmann::json::array( {{{"index", 0}, {"delta", {}}, {"finish_reason", "stop"}}})}}; @@ -255,4 +259,4 @@ std::string ResponseBuilder::buildModelNotFoundResponse() { } } // namespace test -} // namespace ai \ No newline at end of file +} // namespace ai diff --git a/tests/utils/mock_openai_client.h b/tests/utils/mock_openai_client.h index f1f6163..da77039 100644 --- a/tests/utils/mock_openai_client.h +++ b/tests/utils/mock_openai_client.h @@ -98,7 +98,7 @@ class ResponseBuilder { public: static std::string buildSuccessResponse( const std::string& content = "Test response", - const std::string& model = "gpt-4o", + const std::string& model = "test-model", int prompt_tokens = 10, int completion_tokens = 20); diff --git a/tests/utils/test_fixtures.cpp b/tests/utils/test_fixtures.cpp index 9a861f7..54a56af 100644 --- a/tests/utils/test_fixtures.cpp +++ b/tests/utils/test_fixtures.cpp @@ -170,17 +170,17 @@ std::vector TestDataGenerator::generateOptionsVariations() { std::vector variations; // Basic prompt - variations.emplace_back("gpt-4o", "Simple test"); + variations.emplace_back("test-model", "Simple test"); // With system prompt - variations.emplace_back("gpt-4o", "You are helpful", "User question"); + variations.emplace_back("test-model", "You are helpful", "User question"); // With messages Messages msgs = {Message::user("Hello")}; - variations.emplace_back("gpt-4o", std::move(msgs)); + variations.emplace_back("test-model", std::move(msgs)); // With all parameters - GenerateOptions full("gpt-4o", "Test prompt"); + GenerateOptions full("test-model", "Test prompt"); full.temperature = 0.5; full.max_tokens = 50; full.top_p = 0.8; @@ -205,7 +205,7 @@ nlohmann::json TestDataGenerator::createFullValidResponse() { {"id", "chatcmpl-full123"}, {"object", "chat.completion"}, {"created", 1234567890}, - {"model", "gpt-4o"}, + {"model", "test-model"}, {"system_fingerprint", "fp_full123"}, {"choices", nlohmann::json::array( @@ -253,15 +253,18 @@ std::vector TestDataGenerator::createStreamingEvents() { return { "data: " "{\"id\":\"chatcmpl-stream1\",\"object\":\"chat.completion.chunk\"," - "\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0," + "\"created\":1234567890,\"model\":\"test-model\",\"choices\":[{\"index\":" + "0," "\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n", "data: " "{\"id\":\"chatcmpl-stream1\",\"object\":\"chat.completion.chunk\"," - "\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0," + "\"created\":1234567890,\"model\":\"test-model\",\"choices\":[{\"index\":" + "0," "\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n", "data: " "{\"id\":\"chatcmpl-stream1\",\"object\":\"chat.completion.chunk\"," - "\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0," + "\"created\":1234567890,\"model\":\"test-model\",\"choices\":[{\"index\":" + "0," "\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n", "data: [DONE]\n\n"}; } @@ -292,12 +295,12 @@ std::vector TestDataGenerator::createAnthropicStreamingEvents() { } GenerateOptions TestDataGenerator::createEdgeCaseOptions() { - GenerateOptions options("gpt-4o", ""); // Empty prompt - options.temperature = 2.0; // Maximum temperature - options.max_tokens = 1; // Minimum tokens - options.top_p = 1.0; // Maximum top_p - options.frequency_penalty = 2.0; // Maximum penalty - options.presence_penalty = -2.0; // Minimum penalty + GenerateOptions options("test-model", ""); // Empty prompt + options.temperature = 2.0; // Maximum temperature + options.max_tokens = 1; // Minimum tokens + options.top_p = 1.0; // Maximum top_p + options.frequency_penalty = 2.0; // Maximum penalty + options.presence_penalty = -2.0; // Minimum penalty return options; } diff --git a/tests/utils/test_fixtures.h b/tests/utils/test_fixtures.h index 76fe4e9..a754b11 100644 --- a/tests/utils/test_fixtures.h +++ b/tests/utils/test_fixtures.h @@ -19,7 +19,7 @@ class AITestFixture : public ::testing::Test { // Common test data static constexpr const char* kTestApiKey = "sk-test123456789"; - static constexpr const char* kTestModel = "gpt-4o"; + static constexpr const char* kTestModel = "test-model"; static constexpr const char* kTestPrompt = "Hello, world!"; static constexpr const char* kTestBaseUrl = "https://api.openai.com"; diff --git a/third_party/CMakeLists.txt b/third_party/CMakeLists.txt index a4adc22..111a4a4 100644 --- a/third_party/CMakeLists.txt +++ b/third_party/CMakeLists.txt @@ -8,9 +8,6 @@ set(AI_SDK_THIRD_PARTY_DIR ${CMAKE_CURRENT_SOURCE_DIR} CACHE PATH "Third party d # Add zlib (needed by httplib) add_subdirectory(zlib-cmake) -# Add brotli (needed by httplib) -add_subdirectory(brotli-cmake) - # Add httplib add_subdirectory(httplib-cmake) @@ -27,4 +24,4 @@ add_subdirectory(stduuid-cmake) if(BUILD_TESTS) add_subdirectory(googletest-cmake) add_subdirectory(clickhouse-cmake) -endif() \ No newline at end of file +endif() diff --git a/third_party/brotli b/third_party/brotli deleted file mode 160000 index 434b582..0000000 --- a/third_party/brotli +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 434b582d492f92cf91ffe8bf3f5ec3a6438fb90f diff --git a/third_party/brotli-cmake/CMakeLists.txt b/third_party/brotli-cmake/CMakeLists.txt deleted file mode 100644 index 4179591..0000000 --- a/third_party/brotli-cmake/CMakeLists.txt +++ /dev/null @@ -1,22 +0,0 @@ -# Configure brotli build options -set(BROTLI_BUILD_TOOLS OFF CACHE INTERNAL "Disable brotli tools") -set(BROTLI_BUNDLED_MODE ON CACHE INTERNAL "Enable bundled mode") - -# Add brotli as subdirectory -add_subdirectory( - ${AI_SDK_THIRD_PARTY_DIR}/brotli - ${CMAKE_CURRENT_BINARY_DIR}/brotli -) - -# Brotli doesn't create the expected aliases, so we need to create them -if(NOT TARGET Brotli::common) - if(TARGET brotlicommon-static) - add_library(Brotli::common ALIAS brotlicommon-static) - add_library(Brotli::decoder ALIAS brotlidec-static) - add_library(Brotli::encoder ALIAS brotlienc-static) - elseif(TARGET brotlicommon) - add_library(Brotli::common ALIAS brotlicommon) - add_library(Brotli::decoder ALIAS brotlidec) - add_library(Brotli::encoder ALIAS brotlienc) - endif() -endif() \ No newline at end of file diff --git a/third_party/googletest-cmake/CMakeLists.txt b/third_party/googletest-cmake/CMakeLists.txt index 7efda96..609c288 100644 --- a/third_party/googletest-cmake/CMakeLists.txt +++ b/third_party/googletest-cmake/CMakeLists.txt @@ -6,4 +6,5 @@ set(gtest_force_shared_crt ON CACHE INTERNAL "Use shared CRT on Windows") add_subdirectory( ${AI_SDK_THIRD_PARTY_DIR}/googletest ${CMAKE_CURRENT_BINARY_DIR}/googletest -) \ No newline at end of file + EXCLUDE_FROM_ALL +) diff --git a/third_party/httplib-cmake/CMakeLists.txt b/third_party/httplib-cmake/CMakeLists.txt index 5140a08..5d66616 100644 --- a/third_party/httplib-cmake/CMakeLists.txt +++ b/third_party/httplib-cmake/CMakeLists.txt @@ -32,9 +32,6 @@ target_link_libraries(httplib INTERFACE OpenSSL::SSL OpenSSL::Crypto ZLIB::ZLIB - Brotli::common - Brotli::decoder - Brotli::encoder $<$:ws2_32> $<$:crypt32> ) @@ -43,9 +40,8 @@ target_link_libraries(httplib INTERFACE target_compile_definitions(httplib INTERFACE CPPHTTPLIB_OPENSSL_SUPPORT CPPHTTPLIB_ZLIB_SUPPORT - CPPHTTPLIB_BROTLI_SUPPORT CPPHTTPLIB_THREAD_POOL_COUNT=8 ) # Require C++11 minimum (httplib's requirement) -target_compile_features(httplib INTERFACE cxx_std_11) \ No newline at end of file +target_compile_features(httplib INTERFACE cxx_std_11) diff --git a/third_party/zlib-cmake/CMakeLists.txt b/third_party/zlib-cmake/CMakeLists.txt index c9fc1c3..c2ecb1f 100644 --- a/third_party/zlib-cmake/CMakeLists.txt +++ b/third_party/zlib-cmake/CMakeLists.txt @@ -5,6 +5,7 @@ set(ZLIB_BUILD_EXAMPLES OFF CACHE INTERNAL "Disable zlib examples") add_subdirectory( ${AI_SDK_THIRD_PARTY_DIR}/zlib ${CMAKE_CURRENT_BINARY_DIR}/zlib + EXCLUDE_FROM_ALL ) # Create ZLIB::ZLIB alias if it doesn't exist @@ -20,4 +21,4 @@ elseif(TARGET zlib AND NOT TARGET ZLIB::ZLIB) set(ZLIB_LIBRARIES zlib CACHE STRING "ZLIB libraries") get_target_property(ZLIB_INCLUDE_DIRS zlib INTERFACE_INCLUDE_DIRECTORIES) set(ZLIB_INCLUDE_DIRS ${ZLIB_INCLUDE_DIRS} CACHE STRING "ZLIB include directories") -endif() \ No newline at end of file +endif()