33 changed files with 787 additions and 322 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,8 @@ |
|||
{ |
|||
"permissions": { |
|||
"allow": [ |
|||
"WebFetch(domain:openrouter.ai)", |
|||
"WebSearch" |
|||
] |
|||
} |
|||
} |
|||
@ -0,0 +1,324 @@ |
|||
// Fill out your copyright notice in the Description page of Project Settings.
|
|||
|
|||
|
|||
#include "Tools/OpenRouterResponder.h" |
|||
|
|||
#include "HttpModule.h" |
|||
#include "Interfaces/IHttpResponse.h" |
|||
#include "Dom/JsonObject.h" |
|||
#include "Serialization/JsonReader.h" |
|||
#include "Serialization/JsonSerializer.h" |
|||
#include "Serialization/JsonWriter.h" |
|||
|
|||
FString UOpenRouterResponder::StoredApiKey; |
|||
FString UOpenRouterResponder::StoredModel = TEXT("openai/gpt-4.1"); |
|||
FString UOpenRouterResponder::StoredSiteUrl; |
|||
FString UOpenRouterResponder::StoredSiteName; |
|||
TArray<TWeakObjectPtr<UOpenRouterResponder>> UOpenRouterResponder::ActiveRequests; |
|||
|
|||
void UOpenRouterResponder::InitResponder(const FString& ApiKey, const FString& Model, const FString& SiteUrl, const FString& SiteName) |
|||
{ |
|||
StoredApiKey = ApiKey; |
|||
StoredModel = Model; |
|||
StoredSiteUrl = SiteUrl; |
|||
StoredSiteName = SiteName; |
|||
} |
|||
|
|||
UOpenRouterResponder* UOpenRouterResponder::CallOpenRouterResponse( |
|||
UObject* WorldContextObject, |
|||
const FString& Prompt, |
|||
const TArray<FRouterServerToolConfig>& ServerTools, |
|||
const FString& Instructions, |
|||
const FString& OverrideModel, |
|||
int32 MaxOutputTokens, |
|||
ERouterReasoning Reasoning, |
|||
ERouterToolChoice ToolChoice) |
|||
{ |
|||
UOpenRouterResponder* Action = NewObject<UOpenRouterResponder>(); |
|||
Action->InputPrompt = Prompt; |
|||
Action->InputInstructions = Instructions; |
|||
Action->InputModel = OverrideModel.IsEmpty() ? StoredModel : OverrideModel; |
|||
Action->InputMaxOutputTokens = MaxOutputTokens; |
|||
Action->InputReasoning = Reasoning; |
|||
Action->InputServerTools = ServerTools; |
|||
Action->InputToolChoice = ToolChoice; |
|||
Action->RegisterWithGameInstance(WorldContextObject); |
|||
return Action; |
|||
} |
|||
|
|||
void UOpenRouterResponder::Activate() |
|||
{ |
|||
if (StoredApiKey.IsEmpty()) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: API key not set. Call InitResponder first.")); |
|||
Failed.Broadcast(TEXT("API key not set. Call InitResponder first.")); |
|||
return; |
|||
} |
|||
|
|||
RequestStartTime = FPlatformTime::Seconds(); |
|||
ActiveRequests.Add(this); |
|||
|
|||
// ── Build JSON request body ────────────────────────────────────────────
|
|||
|
|||
TSharedRef<FJsonObject> RootObject = MakeShared<FJsonObject>(); |
|||
RootObject->SetStringField(TEXT("model"), InputModel); |
|||
|
|||
// messages array: optional system + user
|
|||
{ |
|||
TArray<TSharedPtr<FJsonValue>> MessagesArray; |
|||
|
|||
if (!InputInstructions.IsEmpty()) |
|||
{ |
|||
TSharedRef<FJsonObject> SystemMsg = MakeShared<FJsonObject>(); |
|||
SystemMsg->SetStringField(TEXT("role"), TEXT("system")); |
|||
SystemMsg->SetStringField(TEXT("content"), InputInstructions); |
|||
MessagesArray.Add(MakeShared<FJsonValueObject>(SystemMsg)); |
|||
} |
|||
|
|||
TSharedRef<FJsonObject> UserMsg = MakeShared<FJsonObject>(); |
|||
UserMsg->SetStringField(TEXT("role"), TEXT("user")); |
|||
UserMsg->SetStringField(TEXT("content"), InputPrompt); |
|||
MessagesArray.Add(MakeShared<FJsonValueObject>(UserMsg)); |
|||
|
|||
RootObject->SetArrayField(TEXT("messages"), MessagesArray); |
|||
} |
|||
|
|||
if (InputMaxOutputTokens > 0) |
|||
{ |
|||
RootObject->SetNumberField(TEXT("max_tokens"), InputMaxOutputTokens); |
|||
} |
|||
|
|||
// Reasoning
|
|||
if (InputReasoning != ERouterReasoning::None) |
|||
{ |
|||
FString EffortStr; |
|||
switch (InputReasoning) |
|||
{ |
|||
case ERouterReasoning::Minimal: EffortStr = TEXT("minimal"); break; |
|||
case ERouterReasoning::Low: EffortStr = TEXT("low"); break; |
|||
case ERouterReasoning::Medium: EffortStr = TEXT("medium"); break; |
|||
case ERouterReasoning::High: EffortStr = TEXT("high"); break; |
|||
case ERouterReasoning::XHigh: EffortStr = TEXT("xhigh"); break; |
|||
default: break; |
|||
} |
|||
|
|||
TSharedRef<FJsonObject> ReasoningObject = MakeShared<FJsonObject>(); |
|||
ReasoningObject->SetStringField(TEXT("effort"), EffortStr); |
|||
ReasoningObject->SetStringField(TEXT("summary"), TEXT("auto")); |
|||
RootObject->SetObjectField(TEXT("reasoning"), ReasoningObject); |
|||
} |
|||
|
|||
// Server tools array
|
|||
if (InputServerTools.Num() > 0) |
|||
{ |
|||
TArray<TSharedPtr<FJsonValue>> ToolsArray; |
|||
|
|||
for (const FRouterServerToolConfig& ToolConfig : InputServerTools) |
|||
{ |
|||
TSharedRef<FJsonObject> ToolObject = MakeShared<FJsonObject>(); |
|||
|
|||
switch (ToolConfig.ToolType) |
|||
{ |
|||
case ERouterServerTool::WebSearch: |
|||
ToolObject->SetStringField(TEXT("type"), TEXT("openrouter:web_search")); |
|||
if (ToolConfig.MaxResults > 0) |
|||
{ |
|||
TSharedRef<FJsonObject> Params = MakeShared<FJsonObject>(); |
|||
Params->SetNumberField(TEXT("max_results"), ToolConfig.MaxResults); |
|||
ToolObject->SetObjectField(TEXT("parameters"), Params); |
|||
} |
|||
break; |
|||
case ERouterServerTool::DateTime: |
|||
ToolObject->SetStringField(TEXT("type"), TEXT("openrouter:datetime")); |
|||
break; |
|||
case ERouterServerTool::ImageGeneration: |
|||
ToolObject->SetStringField(TEXT("type"), TEXT("openrouter:image_generation")); |
|||
break; |
|||
case ERouterServerTool::WebFetch: |
|||
ToolObject->SetStringField(TEXT("type"), TEXT("openrouter:web_fetch")); |
|||
break; |
|||
} |
|||
|
|||
ToolsArray.Add(MakeShared<FJsonValueObject>(ToolObject)); |
|||
} |
|||
|
|||
RootObject->SetArrayField(TEXT("tools"), ToolsArray); |
|||
|
|||
// Only send tool_choice when the caller specified Auto or Required
|
|||
if (InputToolChoice == ERouterToolChoice::Auto) |
|||
{ |
|||
RootObject->SetStringField(TEXT("tool_choice"), TEXT("auto")); |
|||
} |
|||
else if (InputToolChoice == ERouterToolChoice::Required) |
|||
{ |
|||
RootObject->SetStringField(TEXT("tool_choice"), TEXT("required")); |
|||
} |
|||
} |
|||
|
|||
// Serialize to string
|
|||
FString Body; |
|||
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Body); |
|||
FJsonSerializer::Serialize(RootObject, Writer); |
|||
|
|||
// ── Fire HTTP request ──────────────────────────────────────────────────
|
|||
|
|||
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> HttpRequest = FHttpModule::Get().CreateRequest(); |
|||
HttpRequest->SetURL(TEXT("https://openrouter.ai/api/v1/chat/completions")); |
|||
HttpRequest->SetVerb(TEXT("POST")); |
|||
HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json")); |
|||
HttpRequest->SetHeader(TEXT("Authorization"), FString::Printf(TEXT("Bearer %s"), *StoredApiKey)); |
|||
|
|||
if (!StoredSiteUrl.IsEmpty()) |
|||
{ |
|||
HttpRequest->SetHeader(TEXT("HTTP-Referer"), StoredSiteUrl); |
|||
} |
|||
if (!StoredSiteName.IsEmpty()) |
|||
{ |
|||
HttpRequest->SetHeader(TEXT("X-Title"), StoredSiteName); |
|||
} |
|||
|
|||
HttpRequest->SetContentAsString(Body); |
|||
HttpRequest->OnProcessRequestComplete().BindUObject(this, &UOpenRouterResponder::HandleHttpResponse); |
|||
|
|||
if (!HttpRequest->ProcessRequest()) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: Failed to start HTTP request.")); |
|||
Failed.Broadcast(TEXT("Failed to start HTTP request.")); |
|||
} |
|||
} |
|||
|
|||
void UOpenRouterResponder::ClearAllResponses() |
|||
{ |
|||
TArray<TWeakObjectPtr<UOpenRouterResponder>> RequestsCopy = ActiveRequests; |
|||
ActiveRequests.Empty(); |
|||
|
|||
for (const TWeakObjectPtr<UOpenRouterResponder>& Weak : RequestsCopy) |
|||
{ |
|||
if (UOpenRouterResponder* Action = Weak.Get()) |
|||
{ |
|||
Action->SetReadyToDestroy(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
void UOpenRouterResponder::HandleHttpResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) |
|||
{ |
|||
ActiveRequests.RemoveAll([this](const TWeakObjectPtr<UOpenRouterResponder>& Weak) |
|||
{ |
|||
return !Weak.IsValid() || Weak.Get() == this; |
|||
}); |
|||
|
|||
const float ElapsedTime = static_cast<float>(FPlatformTime::Seconds() - RequestStartTime); |
|||
|
|||
if (!bWasSuccessful || !Response.IsValid()) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: HTTP request failed.")); |
|||
Failed.Broadcast(TEXT("HTTP request failed.")); |
|||
return; |
|||
} |
|||
|
|||
const int32 Code = Response->GetResponseCode(); |
|||
const FString Body = Response->GetContentAsString(); |
|||
|
|||
if (Code < 200 || Code >= 300) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: HTTP %d — %s"), Code, *Body); |
|||
Failed.Broadcast(FString::Printf(TEXT("HTTP %d — %s"), Code, *Body)); |
|||
return; |
|||
} |
|||
|
|||
// Parse root JSON
|
|||
TSharedPtr<FJsonObject> RootObject; |
|||
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Body); |
|||
if (!FJsonSerializer::Deserialize(Reader, RootObject) || !RootObject.IsValid()) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: Failed to parse response JSON.")); |
|||
Failed.Broadcast(TEXT("Failed to parse response JSON.")); |
|||
return; |
|||
} |
|||
|
|||
// Check for API-level error
|
|||
if (RootObject->HasField(TEXT("error"))) |
|||
{ |
|||
const TSharedPtr<FJsonObject>* ErrorObject; |
|||
if (RootObject->TryGetObjectField(TEXT("error"), ErrorObject)) |
|||
{ |
|||
FString ErrorMsg = (*ErrorObject)->GetStringField(TEXT("message")); |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: API error — %s"), *ErrorMsg); |
|||
Failed.Broadcast(ErrorMsg); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
// Navigate choices[0].message
|
|||
const TArray<TSharedPtr<FJsonValue>>* ChoicesArray; |
|||
if (!RootObject->TryGetArrayField(TEXT("choices"), ChoicesArray) || ChoicesArray->Num() == 0) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: No choices in response.")); |
|||
Failed.Broadcast(TEXT("No choices in response.")); |
|||
return; |
|||
} |
|||
|
|||
const TSharedPtr<FJsonObject>& FirstChoice = (*ChoicesArray)[0]->AsObject(); |
|||
if (!FirstChoice.IsValid()) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: Invalid choice object.")); |
|||
Failed.Broadcast(TEXT("Invalid choice object.")); |
|||
return; |
|||
} |
|||
|
|||
const TSharedPtr<FJsonObject>* MessageObjectPtr; |
|||
if (!FirstChoice->TryGetObjectField(TEXT("message"), MessageObjectPtr) || !(*MessageObjectPtr).IsValid()) |
|||
{ |
|||
UE_LOG(LogTemp, Error, TEXT("OpenRouterResponder: No message in choice.")); |
|||
Failed.Broadcast(TEXT("No message in choice.")); |
|||
return; |
|||
} |
|||
|
|||
const TSharedPtr<FJsonObject>& MessageObject = *MessageObjectPtr; |
|||
|
|||
FRouterResult Result; |
|||
Result.ExecutionTime = ElapsedTime; |
|||
|
|||
// content can be a plain string or, for multimodal models, an array — handle both
|
|||
{ |
|||
FString ContentStr; |
|||
if (MessageObject->TryGetStringField(TEXT("content"), ContentStr)) |
|||
{ |
|||
Result.Content = ContentStr; |
|||
} |
|||
else |
|||
{ |
|||
const TArray<TSharedPtr<FJsonValue>>* ContentArray; |
|||
if (MessageObject->TryGetArrayField(TEXT("content"), ContentArray)) |
|||
{ |
|||
for (const TSharedPtr<FJsonValue>& Item : *ContentArray) |
|||
{ |
|||
const TSharedPtr<FJsonObject>& ItemObj = Item->AsObject(); |
|||
if (!ItemObj.IsValid()) continue; |
|||
|
|||
FString ItemType; |
|||
FString ItemText; |
|||
if (ItemObj->TryGetStringField(TEXT("type"), ItemType) && ItemType == TEXT("text") |
|||
&& ItemObj->TryGetStringField(TEXT("text"), ItemText)) |
|||
{ |
|||
Result.Content += ItemText; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// reasoning is optional — present only when the model emits chain-of-thought
|
|||
MessageObject->TryGetStringField(TEXT("reasoning"), Result.Reasoning); |
|||
|
|||
if (!Result.Content.IsEmpty() || !Result.Reasoning.IsEmpty()) |
|||
{ |
|||
Success.Broadcast(Result); |
|||
} |
|||
else |
|||
{ |
|||
UE_LOG(LogTemp, Warning, TEXT("OpenRouterResponder: Response contained no content or reasoning.")); |
|||
Failed.Broadcast(TEXT("Response contained no content or reasoning.")); |
|||
} |
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
// Fill out your copyright notice in the Description page of Project Settings.
|
|||
|
|||
#pragma once |
|||
|
|||
#include "CoreMinimal.h" |
|||
#include "Kismet/BlueprintAsyncActionBase.h" |
|||
#include "Http.h" |
|||
#include "OpenRouterResponder.generated.h" |
|||
|
|||
// ── Enums ──────────────────────────────────────────────────────────────────
|
|||
|
|||
UENUM(BlueprintType) |
|||
enum class ERouterToolChoice : uint8 |
|||
{ |
|||
None UMETA(DisplayName = "None (omit)"), |
|||
Auto UMETA(DisplayName = "Auto"), |
|||
Required UMETA(DisplayName = "Required") |
|||
}; |
|||
|
|||
UENUM(BlueprintType) |
|||
enum class ERouterReasoning : uint8 |
|||
{ |
|||
None UMETA(DisplayName = "None"), |
|||
Minimal UMETA(DisplayName = "Minimal"), |
|||
Low UMETA(DisplayName = "Low"), |
|||
Medium UMETA(DisplayName = "Medium"), |
|||
High UMETA(DisplayName = "High"), |
|||
XHigh UMETA(DisplayName = "XHigh") |
|||
}; |
|||
|
|||
UENUM(BlueprintType) |
|||
enum class ERouterServerTool : uint8 |
|||
{ |
|||
WebSearch UMETA(DisplayName = "Web Search"), |
|||
DateTime UMETA(DisplayName = "Date/Time"), |
|||
ImageGeneration UMETA(DisplayName = "Image Generation"), |
|||
WebFetch UMETA(DisplayName = "Web Fetch") |
|||
}; |
|||
|
|||
// ── Structs ────────────────────────────────────────────────────────────────
|
|||
|
|||
USTRUCT(BlueprintType) |
|||
struct FRouterServerToolConfig |
|||
{ |
|||
GENERATED_BODY() |
|||
|
|||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AvatarCoreAI|Router") |
|||
ERouterServerTool ToolType = ERouterServerTool::WebSearch; |
|||
|
|||
/** Maximum results returned. Only used when ToolType is WebSearch. */ |
|||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AvatarCoreAI|Router") |
|||
int32 MaxResults = 3; |
|||
}; |
|||
|
|||
USTRUCT(BlueprintType) |
|||
struct FRouterResult |
|||
{ |
|||
GENERATED_BODY() |
|||
|
|||
/** Main text content from choices[0].message.content */ |
|||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AvatarCoreAI|Router") |
|||
FString Content; |
|||
|
|||
/** Chain-of-thought from choices[0].message.reasoning. Empty if the model does not return it. */ |
|||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AvatarCoreAI|Router") |
|||
FString Reasoning; |
|||
|
|||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AvatarCoreAI|Router") |
|||
float ExecutionTime = 0.f; |
|||
}; |
|||
|
|||
// ── Delegates ──────────────────────────────────────────────────────────────
|
|||
|
|||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnRouterSuccess, const FRouterResult&, Result); |
|||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnRouterFailed, const FString&, ErrorMessage); |
|||
|
|||
// ── Class ──────────────────────────────────────────────────────────────────
|
|||
|
|||
UCLASS() |
|||
class AVATARCORE_AI_API UOpenRouterResponder : public UBlueprintAsyncActionBase |
|||
{ |
|||
GENERATED_BODY() |
|||
|
|||
public: |
|||
/** Store API key, default model, and optional attribution headers sent to OpenRouter. */ |
|||
UFUNCTION(BlueprintCallable, Category = "AvatarCoreAI|Router", meta = (DisplayName = "Init OpenRouter Responder")) |
|||
static void InitResponder( |
|||
const FString& ApiKey, |
|||
const FString& Model = TEXT("openai/gpt-4.1"), |
|||
const FString& SiteUrl = TEXT(""), |
|||
const FString& SiteName = TEXT("")); |
|||
|
|||
/** Send a one-shot request to the OpenRouter Chat Completions API. */ |
|||
UFUNCTION(BlueprintCallable, Category = "AvatarCoreAI|Router", |
|||
meta = (BlueprintInternalUseOnly = "true", DisplayName = "Call OpenRouter Response", WorldContext = "WorldContextObject", |
|||
AutoCreateRefTerm = "Instructions,OverrideModel,ServerTools")) |
|||
static UOpenRouterResponder* CallOpenRouterResponse( |
|||
UObject* WorldContextObject, |
|||
const FString& Prompt, |
|||
const TArray<FRouterServerToolConfig>& ServerTools, |
|||
const FString& Instructions, |
|||
const FString& OverrideModel, |
|||
int32 MaxOutputTokens = 0, |
|||
ERouterReasoning Reasoning = ERouterReasoning::None, |
|||
ERouterToolChoice ToolChoice = ERouterToolChoice::Auto); |
|||
|
|||
/** Cancel all active OpenRouter requests. */ |
|||
UFUNCTION(BlueprintCallable, Category = "AvatarCoreAI|Router", meta = (DisplayName = "Clear All Router Responses")) |
|||
static void ClearAllResponses(); |
|||
|
|||
UPROPERTY(BlueprintAssignable) |
|||
FOnRouterSuccess Success; |
|||
|
|||
UPROPERTY(BlueprintAssignable) |
|||
FOnRouterFailed Failed; |
|||
|
|||
virtual void Activate() override; |
|||
|
|||
private: |
|||
void HandleHttpResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); |
|||
|
|||
FString InputPrompt; |
|||
FString InputInstructions; |
|||
FString InputModel; |
|||
int32 InputMaxOutputTokens = 0; |
|||
ERouterReasoning InputReasoning = ERouterReasoning::None; |
|||
TArray<FRouterServerToolConfig> InputServerTools; |
|||
ERouterToolChoice InputToolChoice = ERouterToolChoice::Auto; |
|||
double RequestStartTime = 0.0; |
|||
|
|||
static FString StoredApiKey; |
|||
static FString StoredModel; |
|||
static FString StoredSiteUrl; |
|||
static FString StoredSiteName; |
|||
static TArray<TWeakObjectPtr<UOpenRouterResponder>> ActiveRequests; |
|||
}; |
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue