951 lines
27 KiB
GDScript
951 lines
27 KiB
GDScript
extends Node
|
|
|
|
## GumYum Game Client for Godot (API Key Authentication Only)
|
|
##
|
|
## Streamlined client for game integration using API keys only.
|
|
## Perfect for embedding in shipped games with just the essential features.
|
|
##
|
|
## This client does NOT support username/password authentication.
|
|
## Use API keys obtained from the GumYum dashboard.
|
|
##
|
|
## Features:
|
|
## - Character spawning and dialogue
|
|
## - API key authentication only (no login/register)
|
|
## - Automatic JWT token management via API key exchange
|
|
## - Automatic retry and error handling
|
|
## - Streaming dialogue support
|
|
|
|
signal character_spawned(character: GumYumNPC)
|
|
signal dialogue_received(response: String, context: Dictionary, mood_transition: Dictionary)
|
|
signal dialogue_chunk_received(chunk: String)
|
|
signal dialogue_stream_started
|
|
signal dialogue_stream_ended(
|
|
full_response: String, context: Dictionary, mood_transition: Dictionary
|
|
)
|
|
signal request_completed(endpoint: String, data: Dictionary)
|
|
signal request_failed(endpoint: String, error: String)
|
|
|
|
## Manager Classes
|
|
|
|
|
|
class NPCManager:
|
|
var client: Node
|
|
|
|
func _init(c: Node) -> void:
|
|
client = c
|
|
|
|
## Spawn a character - returns GumYumNPC object
|
|
## Pass null for npc_id to spawn a random NPC
|
|
func spawn(
|
|
universe_id: String, seed: int, npc_id = null, callback: Callable = Callable()
|
|
) -> void:
|
|
var params = {"universe_id": universe_id, "seed": seed}
|
|
var endpoint: String
|
|
|
|
if npc_id != null:
|
|
# Spawn specific NPC
|
|
params["npc_id"] = npc_id
|
|
endpoint = "npc"
|
|
else:
|
|
# Spawn random NPC - let API handle the randomness
|
|
endpoint = "npc/spawn"
|
|
|
|
# Make the API request
|
|
client.request(
|
|
HTTPClient.METHOD_GET,
|
|
endpoint,
|
|
{},
|
|
params,
|
|
func(response):
|
|
# Check if this is a redirect response
|
|
if response.has("redirect_url"):
|
|
# Follow the redirect to get actual NPC data
|
|
var redirect_path = response.redirect_url
|
|
# Strip the /v1/ prefix since client adds it
|
|
if redirect_path.begins_with("/v1/"):
|
|
redirect_path = redirect_path.substr(4)
|
|
|
|
# Make the redirect request
|
|
client.request(
|
|
HTTPClient.METHOD_GET,
|
|
redirect_path,
|
|
{},
|
|
{},
|
|
func(npc_data):
|
|
if not npc_data.has("error"):
|
|
# Create NPC object with client reference
|
|
var npc = GumYumNPC.new(npc_data, client)
|
|
npc._connect_client_signals()
|
|
client.character_spawned.emit(npc)
|
|
if callback.is_valid():
|
|
var npc_obj = (
|
|
GumYumNPC.new(npc_data, client)
|
|
if not npc_data.has("error")
|
|
else null
|
|
)
|
|
if npc_obj:
|
|
npc_obj._connect_client_signals()
|
|
callback.call(npc_obj if npc_obj else npc_data)
|
|
)
|
|
else:
|
|
# Direct response (shouldn't happen with /npc/spawn)
|
|
if not response.has("error"):
|
|
var npc = GumYumNPC.new(response, client)
|
|
npc._connect_client_signals()
|
|
client.character_spawned.emit(npc)
|
|
if callback.is_valid():
|
|
callback.call(npc)
|
|
)
|
|
|
|
## Spawn with advanced filters - supports any NPC field
|
|
## Examples:
|
|
## spawn_filtered("kingdom", 12345, {"profession": ["warrior", "knight"], "location": ["barracks", "castle"]})
|
|
## spawn_filtered("blade-runner", 67890, {"personality_type": [8], "sex": ["female"]})
|
|
func spawn_filtered(
|
|
universe_id: String, seed: int, filters: Dictionary = {}, callback: Callable = Callable()
|
|
) -> void:
|
|
var data = {"universe_id": universe_id, "world_seed": seed, "filters": filters}
|
|
|
|
var endpoint = "npc/spawn_filtered"
|
|
|
|
# spawn_filtered uses POST with data in body
|
|
client.request(
|
|
HTTPClient.METHOD_POST,
|
|
endpoint,
|
|
data,
|
|
{},
|
|
func(response):
|
|
# Check if this is a redirect response
|
|
if response.has("redirect_url"):
|
|
# Follow the redirect to get actual NPC data
|
|
var redirect_path = response.redirect_url
|
|
# Strip the /v1/ prefix since client adds it
|
|
if redirect_path.begins_with("/v1/"):
|
|
redirect_path = redirect_path.substr(4)
|
|
|
|
# Make the redirect request
|
|
client.request(
|
|
HTTPClient.METHOD_GET,
|
|
redirect_path,
|
|
{},
|
|
{},
|
|
func(npc_data):
|
|
if not npc_data.has("error"):
|
|
# Create NPC object with client reference
|
|
var npc = GumYumNPC.new(npc_data, client)
|
|
npc._connect_client_signals()
|
|
client.character_spawned.emit(npc)
|
|
if callback.is_valid():
|
|
var npc_obj = (
|
|
GumYumNPC.new(npc_data, client)
|
|
if not npc_data.has("error")
|
|
else null
|
|
)
|
|
if npc_obj:
|
|
npc_obj._connect_client_signals()
|
|
callback.call(npc_obj if npc_obj else npc_data)
|
|
)
|
|
else:
|
|
# Direct response (shouldn't happen with /npc/spawn)
|
|
if not response.has("error"):
|
|
var npc = GumYumNPC.new(response, client)
|
|
npc._connect_client_signals()
|
|
client.character_spawned.emit(npc)
|
|
if callback.is_valid():
|
|
callback.call(npc)
|
|
else:
|
|
if callback.is_valid():
|
|
callback.call(response)
|
|
)
|
|
|
|
|
|
class UniverseManager:
|
|
var client: Node
|
|
|
|
func _init(c: Node) -> void:
|
|
client = c
|
|
|
|
## List public universes (works with API keys)
|
|
func list_public(callback: Callable) -> void:
|
|
client.request(HTTPClient.METHOD_GET, "public/universes", {}, {}, callback)
|
|
|
|
## List user's universes (works with JWT from API key exchange)
|
|
func list(callback: Callable) -> void:
|
|
client.request(HTTPClient.METHOD_GET, "universes", {}, {}, callback)
|
|
|
|
func copy(
|
|
_public_universe_id: String, _custom_name: String = "", callback: Callable = Callable()
|
|
) -> void:
|
|
push_error(
|
|
"[GumYum] copy() requires user authentication. Use API keys with a known universe ID instead."
|
|
)
|
|
if callback.is_valid():
|
|
callback.call(
|
|
{"error": "Authentication required. Use API keys with a known universe ID."}
|
|
)
|
|
|
|
func get_universe(universe_id: String, callback: Callable) -> void:
|
|
client.request(HTTPClient.METHOD_GET, "universes/" + universe_id, {}, {}, callback)
|
|
|
|
|
|
class ChatManager:
|
|
var client: Node
|
|
|
|
func _init(c: Node) -> void:
|
|
client = c
|
|
|
|
## Generate dialogue (OpenAI-compatible chat.completions)
|
|
func completions(
|
|
messages: Array,
|
|
npc_params: Dictionary,
|
|
temperature: float = 0.8,
|
|
callback: Callable = Callable(),
|
|
max_tokens: int = 2000,
|
|
stream: bool = false
|
|
) -> void:
|
|
var data = {
|
|
"model": "gumyum-npc",
|
|
"temperature": temperature,
|
|
"messages": messages,
|
|
"stream": stream,
|
|
"npc_params": npc_params,
|
|
"max_tokens": max_tokens
|
|
}
|
|
|
|
# Extract npc_id to top level if it exists in npc_params
|
|
if npc_params.has("npc_id"):
|
|
data["npc_id"] = npc_params["npc_id"]
|
|
|
|
if stream:
|
|
# Use streaming method
|
|
client._request_stream(HTTPClient.METHOD_POST, "chat/completions", data, {}, callback)
|
|
else:
|
|
client.request(
|
|
HTTPClient.METHOD_POST,
|
|
"chat/completions",
|
|
data,
|
|
{},
|
|
func(response):
|
|
if (
|
|
not response.has("error")
|
|
and response.has("choices")
|
|
and response.choices.size() > 0
|
|
):
|
|
var content = response.choices[0].message.content
|
|
var context = response.get("npc_context", {})
|
|
var mood_transition = response.get("mood_transition", {})
|
|
client.dialogue_received.emit(content, context, mood_transition)
|
|
if callback.is_valid():
|
|
callback.call(response)
|
|
)
|
|
|
|
## Generate dialogue (legacy method name for backwards compatibility)
|
|
func chat(
|
|
messages: Array,
|
|
npc_params: Dictionary,
|
|
temperature: float = 0.8,
|
|
callback: Callable = Callable(),
|
|
max_tokens: int = 2000
|
|
) -> void:
|
|
completions(messages, npc_params, temperature, callback, max_tokens, false)
|
|
|
|
## Generate dialogue with streaming support
|
|
func chat_stream(
|
|
messages: Array,
|
|
npc_params: Dictionary,
|
|
temperature: float = 0.8,
|
|
callback: Callable = Callable(),
|
|
max_tokens: int = 2000
|
|
) -> void:
|
|
var data = {
|
|
"model": "gumyum-npc",
|
|
"temperature": temperature,
|
|
"messages": messages,
|
|
"stream": true, # Enable streaming
|
|
"npc_params": npc_params,
|
|
"max_tokens": max_tokens
|
|
}
|
|
|
|
# Extract npc_id to top level if it exists in npc_params
|
|
if npc_params.has("npc_id"):
|
|
data["npc_id"] = npc_params["npc_id"]
|
|
|
|
# Use the streaming request method
|
|
client._request_stream(HTTPClient.METHOD_POST, "chat/completions", data, {}, callback)
|
|
|
|
|
|
## Configuration
|
|
@export var base_url: String = "https://npc.gumyum.com"
|
|
@export var api_version: String = "v1"
|
|
@export var timeout: float = 75.0 # Higher than server's 65s timeout
|
|
@export var max_retries: int = 3
|
|
@export var debug_mode: bool = false
|
|
|
|
## API Credentials
|
|
var _api_key: String = "" # The public API key
|
|
var _api_secret: String = "" # The secret key
|
|
var _jwt_token: String = ""
|
|
var _refresh_token: String = ""
|
|
var _token_expires_at: float = 0.0
|
|
|
|
## Set API credentials - matches Python client interface
|
|
var api_key: String:
|
|
set(value):
|
|
_api_key = value
|
|
if debug_mode:
|
|
print("[GumYum Game] API key set: ", value)
|
|
get:
|
|
return _api_key
|
|
|
|
var api_secret: String:
|
|
set(value):
|
|
_api_secret = value
|
|
if debug_mode:
|
|
print("[GumYum Game] API secret set: ****")
|
|
get:
|
|
return _api_secret
|
|
|
|
## HTTP Clients for parallel requests
|
|
var _http_clients: Array = [] # Pool of HTTP clients
|
|
var _max_parallel_requests: int = 5 # Max concurrent requests
|
|
var _pending_requests: Array = []
|
|
var _active_requests: int = 0
|
|
var _auth_in_progress: bool = false # Prevent concurrent auth requests
|
|
|
|
## Managers (initialized in _ready)
|
|
var npcs: NPCManager
|
|
var universes: UniverseManager
|
|
var chat: ChatManager
|
|
|
|
|
|
func _ready() -> void:
|
|
_setup_http_client()
|
|
_initialize_managers()
|
|
|
|
# No need to load auth for API key-only authentication
|
|
|
|
if debug_mode:
|
|
print("[GumYum Game] Client initialized")
|
|
|
|
|
|
func _setup_http_client() -> void:
|
|
# Create a pool of HTTP clients for parallel requests
|
|
for i in range(_max_parallel_requests):
|
|
var http_client = HTTPRequest.new()
|
|
http_client.name = "HTTPClient_" + str(i)
|
|
add_child(http_client)
|
|
http_client.timeout = timeout
|
|
http_client.request_completed.connect(_on_request_completed.bind(http_client))
|
|
_http_clients.append(http_client)
|
|
|
|
|
|
func _initialize_managers() -> void:
|
|
npcs = NPCManager.new(self)
|
|
universes = UniverseManager.new(self)
|
|
chat = ChatManager.new(self)
|
|
|
|
|
|
## Check if authenticated
|
|
func is_ready() -> bool:
|
|
return _jwt_token != "" and Time.get_ticks_msec() / 1000.0 < _token_expires_at
|
|
|
|
|
|
## Check if token needs refresh (within 60 seconds of expiry)
|
|
func _needs_refresh() -> bool:
|
|
return _jwt_token != "" and Time.get_ticks_msec() / 1000.0 > (_token_expires_at - 60)
|
|
|
|
|
|
## Get full API URL
|
|
func get_api_url(endpoint: String) -> String:
|
|
return "%s/%s/%s" % [base_url, api_version, endpoint.lstrip("/")]
|
|
|
|
|
|
## Get request headers
|
|
func get_headers() -> PackedStringArray:
|
|
var headers = PackedStringArray()
|
|
headers.append("Content-Type: application/json")
|
|
headers.append("User-Agent: GumYum-Godot-Game-SDK/1.0.0")
|
|
|
|
# Add API key header if we have credentials
|
|
if _api_key and _api_secret:
|
|
headers.append("X-API-Key: " + _api_key + ":" + _api_secret)
|
|
|
|
if _jwt_token:
|
|
headers.append("Authorization: Bearer " + _jwt_token)
|
|
|
|
return headers
|
|
|
|
|
|
## Make API request
|
|
func request(
|
|
method: HTTPClient.Method,
|
|
endpoint: String,
|
|
data: Dictionary = {},
|
|
query_params: Dictionary = {},
|
|
callback: Callable = Callable()
|
|
) -> void:
|
|
# Queue the request
|
|
var request_data = {
|
|
"method": method,
|
|
"endpoint": endpoint,
|
|
"data": data,
|
|
"query_params": query_params,
|
|
"callback": callback,
|
|
"retries": 0
|
|
}
|
|
|
|
_pending_requests.append(request_data)
|
|
_process_request_queue()
|
|
|
|
|
|
func _process_request_queue() -> void:
|
|
# Process multiple requests in parallel up to the limit
|
|
while _active_requests < _max_parallel_requests and _pending_requests.size() > 0:
|
|
# Check if we need to exchange API key first
|
|
if _api_key != "" and _api_secret != "" and not is_ready():
|
|
_exchange_api_key_for_jwt()
|
|
return
|
|
|
|
# Check if we need to refresh token
|
|
if _needs_refresh() and _refresh_token != "":
|
|
_refresh_jwt_token()
|
|
return
|
|
|
|
# Find an available HTTP client
|
|
var available_client: HTTPRequest = null
|
|
for client in _http_clients:
|
|
if client.get_meta("busy", false) == false:
|
|
available_client = client
|
|
break
|
|
|
|
if available_client:
|
|
var request = _pending_requests.pop_front()
|
|
available_client.set_meta("busy", true)
|
|
available_client.set_meta("request_data", request)
|
|
_active_requests += 1
|
|
_execute_request(request, available_client)
|
|
|
|
|
|
func _execute_request(request: Dictionary, http_client: HTTPRequest) -> void:
|
|
var url = get_api_url(request.endpoint)
|
|
|
|
# Add query parameters
|
|
if request.query_params.size() > 0:
|
|
var query_parts = []
|
|
for key in request.query_params:
|
|
query_parts.append(key + "=" + str(request.query_params[key]).uri_encode())
|
|
url += "?" + "&".join(query_parts)
|
|
|
|
var headers = get_headers()
|
|
var body = ""
|
|
|
|
if request.method in [HTTPClient.METHOD_POST, HTTPClient.METHOD_PUT]:
|
|
body = JSON.stringify(request.data)
|
|
|
|
if debug_mode:
|
|
print("[GumYum Game] %s %s" % [request.method, request.endpoint])
|
|
print("[GumYum Game] Full URL: %s" % url)
|
|
|
|
var error = http_client.request_raw(
|
|
url, headers, request.method, body.to_utf8_buffer() if body else PackedByteArray()
|
|
)
|
|
|
|
if error != OK:
|
|
_handle_request_error(request, "Failed to make request: " + error_string(error))
|
|
http_client.set_meta("busy", false)
|
|
_active_requests -= 1
|
|
_process_request_queue()
|
|
|
|
|
|
func _on_request_completed(
|
|
result: int,
|
|
response_code: int,
|
|
_headers: PackedStringArray,
|
|
body: PackedByteArray,
|
|
http_client: HTTPRequest
|
|
) -> void:
|
|
var request = http_client.get_meta("request_data", {})
|
|
if request.is_empty():
|
|
return
|
|
|
|
# Mark client as available
|
|
http_client.set_meta("busy", false)
|
|
http_client.remove_meta("request_data")
|
|
_active_requests -= 1
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS:
|
|
_handle_request_error(request, "Request failed: " + str(result))
|
|
return
|
|
|
|
var response_text = body.get_string_from_utf8()
|
|
var response_data = {}
|
|
|
|
if response_text:
|
|
var json = JSON.new()
|
|
if json.parse(response_text) == OK:
|
|
response_data = json.data
|
|
else:
|
|
print("[GumYum Game] Failed to parse JSON response: ", response_text)
|
|
|
|
if debug_mode:
|
|
print("[GumYum Game] Response code: ", response_code)
|
|
if response_code < 200 or response_code >= 300:
|
|
print("[GumYum Game] Response body: ", response_text.substr(0, 200)) # First 200 chars
|
|
|
|
if response_code >= 200 and response_code < 300:
|
|
_handle_success_response(request, response_data)
|
|
else:
|
|
_handle_error_response(request, response_code, response_data)
|
|
|
|
# Process next request
|
|
_process_request_queue()
|
|
|
|
|
|
func _handle_success_response(request: Dictionary, data: Dictionary) -> void:
|
|
request_completed.emit(request.endpoint, data)
|
|
|
|
if request.callback.is_valid():
|
|
request.callback.call(data)
|
|
|
|
|
|
func _handle_error_response(request: Dictionary, code: int, data: Dictionary) -> void:
|
|
var error_msg = data.get("message", data.get("error", "Unknown error"))
|
|
|
|
if debug_mode:
|
|
print(
|
|
(
|
|
"[GumYum Game] Error response for %s: HTTP %d - %s"
|
|
% [request.endpoint, code, error_msg]
|
|
)
|
|
)
|
|
if data.has("details"):
|
|
print("[GumYum Game] Details: ", data.get("details"))
|
|
|
|
# Handle 403 - try to refresh token only for expired tokens
|
|
if code == 403 and _refresh_token != "" and request.retries == 0:
|
|
# Check if it's specifically an expired token error
|
|
var error_message = data.get("error", "").to_lower()
|
|
if error_message.contains("expired") and error_message.contains("token"):
|
|
if debug_mode:
|
|
print("[GumYum Game] Got 403 with expired token, attempting token refresh...")
|
|
request.retries += 1
|
|
_pending_requests.push_front(request)
|
|
_refresh_jwt_token()
|
|
return
|
|
|
|
# For 401 errors (like revoked API keys), don't retry - just fail
|
|
if code == 401:
|
|
if debug_mode:
|
|
print("[GumYum Game] Got 401 Unauthorized - not retrying")
|
|
_handle_request_error(request, "Error %d: %s" % [code, error_msg])
|
|
return
|
|
|
|
# Retry logic for temporary failures
|
|
if code >= 500 and request.retries < max_retries:
|
|
request.retries += 1
|
|
if debug_mode:
|
|
print("[GumYum Game] Retrying request (attempt %d/%d)" % [request.retries, max_retries])
|
|
|
|
# Exponential backoff
|
|
await get_tree().create_timer(pow(2, request.retries)).timeout
|
|
_pending_requests.push_front(request)
|
|
return
|
|
|
|
_handle_request_error(request, "Error %d: %s" % [code, error_msg])
|
|
|
|
|
|
func _handle_request_error(request: Dictionary, error: String) -> void:
|
|
if debug_mode:
|
|
print("[GumYum Game] Request failed: %s - %s" % [request.endpoint, error])
|
|
|
|
request_failed.emit(request.endpoint, error)
|
|
|
|
if request.callback.is_valid():
|
|
request.callback.call({"error": error})
|
|
|
|
|
|
func _exchange_api_key_for_jwt() -> void:
|
|
if _api_key == "" or _api_secret == "":
|
|
return
|
|
|
|
if _auth_in_progress:
|
|
return # Already exchanging, skip
|
|
|
|
_auth_in_progress = true
|
|
|
|
var data = {"public_key": _api_key, "secret_key": _api_secret}
|
|
|
|
var url = get_api_url("auth/exchange")
|
|
var headers = get_headers()
|
|
var body = JSON.stringify(data)
|
|
|
|
# Use first HTTP client for auth
|
|
var auth_client = _http_clients[0]
|
|
var error = auth_client.request_raw(url, headers, HTTPClient.METHOD_POST, body.to_utf8_buffer())
|
|
|
|
if error == OK:
|
|
# Wait for response
|
|
var result = await auth_client.request_completed
|
|
|
|
if result[1] == 200: # response_code
|
|
var response_text = result[3].get_string_from_utf8() # body
|
|
var json = JSON.new()
|
|
if json.parse(response_text) == OK:
|
|
var response_data = json.data
|
|
_jwt_token = response_data.get("access_token", "")
|
|
_refresh_token = response_data.get("refresh_token", "")
|
|
var expires_in = response_data.get("expires_in", 3600)
|
|
_token_expires_at = Time.get_ticks_msec() / 1000.0 + expires_in
|
|
|
|
if debug_mode:
|
|
print("[GumYum Game] API key exchanged for JWT")
|
|
print("[GumYum Game] Got refresh token: ", _refresh_token != "")
|
|
|
|
_auth_in_progress = false
|
|
|
|
# Process queued requests
|
|
_process_request_queue()
|
|
else:
|
|
# Handle authentication error
|
|
var error_text = result[3].get_string_from_utf8() if result[3] else "Unknown error"
|
|
push_error(
|
|
"[GumYum Game] API key exchange failed (code %d): %s" % [result[1], error_text]
|
|
)
|
|
|
|
# Try to parse error message
|
|
var json = JSON.new()
|
|
if json.parse(error_text) == OK and json.data is Dictionary:
|
|
var error_msg = json.data.get("error", json.data.get("message", "Invalid API keys"))
|
|
_auth_in_progress = false
|
|
# Process pending requests with error
|
|
while _pending_requests.size() > 0:
|
|
var req = _pending_requests.pop_front()
|
|
_handle_request_error(req, "Authentication failed: " + error_msg)
|
|
else:
|
|
_auth_in_progress = false
|
|
# Process pending requests with generic error
|
|
while _pending_requests.size() > 0:
|
|
var req = _pending_requests.pop_front()
|
|
_handle_request_error(req, "Invalid public key or secret key")
|
|
else:
|
|
_auth_in_progress = false
|
|
push_error("[GumYum Game] Failed to make exchange request: " + error_string(error))
|
|
|
|
|
|
func _refresh_jwt_token() -> void:
|
|
if _refresh_token == "":
|
|
return
|
|
|
|
if _auth_in_progress:
|
|
return # Already refreshing, skip
|
|
|
|
_auth_in_progress = true
|
|
|
|
var data = {"refresh_token": _refresh_token}
|
|
|
|
var url = get_api_url("auth/refresh")
|
|
var headers = get_headers()
|
|
var body = JSON.stringify(data)
|
|
|
|
if debug_mode:
|
|
print("[GumYum Game] Refreshing JWT token...")
|
|
|
|
# Use first HTTP client for auth
|
|
var auth_client = _http_clients[0]
|
|
var error = auth_client.request_raw(url, headers, HTTPClient.METHOD_POST, body.to_utf8_buffer())
|
|
|
|
if error == OK:
|
|
# Wait for response
|
|
var result = await auth_client.request_completed
|
|
|
|
if result[1] == 200: # response_code
|
|
var response_text = result[3].get_string_from_utf8() # body
|
|
var json = JSON.new()
|
|
if json.parse(response_text) == OK:
|
|
var response_data = json.data
|
|
_jwt_token = response_data.get("access_token", "")
|
|
# Server may rotate refresh token
|
|
if response_data.has("refresh_token"):
|
|
_refresh_token = response_data.get("refresh_token", "")
|
|
var expires_in = response_data.get("expires_in", 3600)
|
|
_token_expires_at = Time.get_ticks_msec() / 1000.0 + expires_in
|
|
|
|
if debug_mode:
|
|
print("[GumYum Game] JWT token refreshed successfully")
|
|
|
|
_auth_in_progress = false
|
|
|
|
# Process queued requests
|
|
_process_request_queue()
|
|
elif result[1] == 403: # Forbidden - refresh token invalid
|
|
# Handle refresh token expiry
|
|
var error_text = result[3].get_string_from_utf8() if result[3] else "Unknown error"
|
|
push_error(
|
|
"[GumYum Game] Refresh token expired or invalid (code 403): %s" % [error_text]
|
|
)
|
|
|
|
# Clear tokens
|
|
_jwt_token = ""
|
|
_refresh_token = ""
|
|
_auth_in_progress = false
|
|
|
|
# If we have API keys, try to re-exchange
|
|
if _api_key != "":
|
|
if debug_mode:
|
|
print("[GumYum Game] Refresh failed, re-exchanging API key...")
|
|
_exchange_api_key_for_jwt()
|
|
else:
|
|
# Process pending requests with error
|
|
while _pending_requests.size() > 0:
|
|
var req = _pending_requests.pop_front()
|
|
_handle_request_error(req, "Authentication expired - please re-authenticate")
|
|
else:
|
|
# Handle other refresh errors
|
|
var error_text = result[3].get_string_from_utf8() if result[3] else "Unknown error"
|
|
push_error("[GumYum Game] Token refresh failed (code %d): %s" % [result[1], error_text])
|
|
|
|
_auth_in_progress = false
|
|
|
|
# Process pending requests with error
|
|
while _pending_requests.size() > 0:
|
|
var req = _pending_requests.pop_front()
|
|
_handle_request_error(req, "Token refresh failed")
|
|
else:
|
|
_auth_in_progress = false
|
|
push_error("[GumYum Game] Failed to make refresh request: " + error_string(error))
|
|
|
|
|
|
## Internal method for streaming requests
|
|
func _request_stream(
|
|
method: HTTPClient.Method,
|
|
endpoint: String,
|
|
data: Dictionary = {},
|
|
query_params: Dictionary = {},
|
|
callback: Callable = Callable()
|
|
) -> void:
|
|
# Check if we need to wait for auth
|
|
if not is_ready():
|
|
push_error("[GumYum Stream] Not authenticated yet")
|
|
if callback.is_valid():
|
|
callback.call({"error": "Not authenticated"})
|
|
return
|
|
|
|
# Create a new thread for streaming
|
|
var thread = Thread.new()
|
|
thread.start(_stream_request_thread.bind(method, endpoint, data, query_params, callback))
|
|
|
|
|
|
func _stream_request_thread(
|
|
method: HTTPClient.Method,
|
|
endpoint: String,
|
|
data: Dictionary,
|
|
query_params: Dictionary,
|
|
callback: Callable
|
|
) -> void:
|
|
var http = HTTPClient.new()
|
|
var url = get_api_url(endpoint)
|
|
var parsed_url = url.split("/")
|
|
var host = parsed_url[2].split(":")[0]
|
|
var port = 443 if url.begins_with("https") else 80
|
|
|
|
if ":" in parsed_url[2]:
|
|
port = int(parsed_url[2].split(":")[1])
|
|
|
|
var path = "/" + "/".join(parsed_url.slice(3))
|
|
|
|
if debug_mode:
|
|
print("[GumYum Stream] Connecting to %s:%d%s" % [host, port, path])
|
|
|
|
# Add query parameters
|
|
if query_params.size() > 0:
|
|
var query_parts = []
|
|
for key in query_params:
|
|
query_parts.append(key + "=" + str(query_params[key]).uri_encode())
|
|
path += "?" + "&".join(query_parts)
|
|
|
|
# Connect to server
|
|
var err = OK
|
|
if port == 443:
|
|
# For HTTPS, create TLS options
|
|
var tls_options = TLSOptions.client()
|
|
err = http.connect_to_host(host, port, tls_options)
|
|
else:
|
|
# For HTTP, no TLS needed
|
|
err = http.connect_to_host(host, port)
|
|
|
|
if err != OK:
|
|
call_deferred("_handle_stream_error", endpoint, "Failed to connect: " + str(err), callback)
|
|
return
|
|
|
|
# Wait for connection
|
|
while (
|
|
http.get_status() == HTTPClient.STATUS_CONNECTING
|
|
or http.get_status() == HTTPClient.STATUS_RESOLVING
|
|
):
|
|
http.poll()
|
|
OS.delay_msec(10)
|
|
|
|
if http.get_status() != HTTPClient.STATUS_CONNECTED:
|
|
call_deferred("_handle_stream_error", endpoint, "Connection failed", callback)
|
|
return
|
|
|
|
# Prepare headers
|
|
var headers = get_headers()
|
|
headers.append("Accept: text/event-stream")
|
|
|
|
# Make request
|
|
var body_string = JSON.stringify(data)
|
|
err = http.request(method, path, headers, body_string)
|
|
|
|
if err != OK:
|
|
call_deferred("_handle_stream_error", endpoint, "Request failed: " + str(err), callback)
|
|
return
|
|
|
|
# Process streaming response
|
|
call_deferred("emit_signal", "dialogue_stream_started")
|
|
|
|
var response_data = ""
|
|
var accumulated_content = ""
|
|
var context = {}
|
|
var mood_transition = {}
|
|
var chunk_count = 0
|
|
|
|
while http.get_status() == HTTPClient.STATUS_REQUESTING:
|
|
http.poll()
|
|
OS.delay_msec(1)
|
|
|
|
if (
|
|
http.get_status() != HTTPClient.STATUS_BODY
|
|
and http.get_status() != HTTPClient.STATUS_CONNECTED
|
|
):
|
|
call_deferred(
|
|
"_handle_stream_error",
|
|
endpoint,
|
|
"Request failed with status: " + str(http.get_status()),
|
|
callback
|
|
)
|
|
return
|
|
|
|
if debug_mode:
|
|
print("[GumYum Stream] Starting to read response body...")
|
|
|
|
# Read streaming response
|
|
while http.get_status() == HTTPClient.STATUS_BODY:
|
|
http.poll()
|
|
var chunk = http.read_response_body_chunk()
|
|
|
|
if chunk.size() > 0:
|
|
response_data += chunk.get_string_from_utf8()
|
|
|
|
# Process SSE events
|
|
var lines = response_data.split("\n")
|
|
for i in range(lines.size() - 1):
|
|
var line = lines[i].strip_edges()
|
|
|
|
if line.begins_with("data: "):
|
|
var data_str = line.substr(6)
|
|
if debug_mode:
|
|
print("[GumYum Stream] Received data: " + data_str.substr(0, 100))
|
|
|
|
if data_str == "[DONE]" or data_str == "done":
|
|
# Stream finished
|
|
if debug_mode:
|
|
print(
|
|
(
|
|
"[GumYum Stream] Stream complete, total content: "
|
|
+ str(accumulated_content.length())
|
|
+ " chars"
|
|
)
|
|
)
|
|
call_deferred(
|
|
"emit_signal",
|
|
"dialogue_stream_ended",
|
|
accumulated_content,
|
|
context,
|
|
mood_transition
|
|
)
|
|
if callback.is_valid():
|
|
call_deferred(
|
|
"_call_callback",
|
|
callback,
|
|
{
|
|
"choices": [{"message": {"content": accumulated_content}}],
|
|
"npc_context": context,
|
|
"mood_transition": mood_transition
|
|
}
|
|
)
|
|
return
|
|
|
|
# Parse JSON data
|
|
var json = JSON.new()
|
|
if json.parse(data_str) == OK:
|
|
var event_data = json.data
|
|
|
|
# Handle different event types
|
|
if event_data.has("choices") and event_data.choices.size() > 0:
|
|
var choice = event_data.choices[0]
|
|
if choice.has("delta") and choice.delta.has("content"):
|
|
var content_chunk = choice.delta.content
|
|
accumulated_content += content_chunk
|
|
chunk_count += 1
|
|
if debug_mode:
|
|
print(
|
|
(
|
|
"[GumYum Stream] Chunk %d: %s"
|
|
% [chunk_count, content_chunk]
|
|
)
|
|
)
|
|
call_deferred(
|
|
"emit_signal", "dialogue_chunk_received", content_chunk
|
|
)
|
|
elif choice.has("message") and choice.message.has("content"):
|
|
# Full message format
|
|
var content = choice.message.content
|
|
accumulated_content = content
|
|
if debug_mode:
|
|
print(
|
|
(
|
|
"[GumYum Stream] Full message received: "
|
|
+ content.substr(0, 50)
|
|
+ "..."
|
|
)
|
|
)
|
|
call_deferred("emit_signal", "dialogue_chunk_received", content)
|
|
|
|
# Check for context and mood transition
|
|
if event_data.has("npc_context"):
|
|
context = event_data.npc_context
|
|
if event_data.has("mood_transition"):
|
|
mood_transition = event_data.mood_transition
|
|
|
|
# Keep the last incomplete line for next iteration
|
|
response_data = lines[lines.size() - 1]
|
|
|
|
OS.delay_msec(1)
|
|
|
|
# Ensure we emit the final response
|
|
call_deferred(
|
|
"emit_signal", "dialogue_stream_ended", accumulated_content, context, mood_transition
|
|
)
|
|
if callback.is_valid():
|
|
call_deferred(
|
|
"_call_callback",
|
|
callback,
|
|
{
|
|
"choices": [{"message": {"content": accumulated_content}}],
|
|
"npc_context": context,
|
|
"mood_transition": mood_transition
|
|
}
|
|
)
|
|
|
|
|
|
func _handle_stream_error(endpoint: String, error: String, callback: Callable) -> void:
|
|
push_error("[GumYum Game] Stream error on %s: %s" % [endpoint, error])
|
|
request_failed.emit(endpoint, error)
|
|
if callback.is_valid():
|
|
callback.call({"error": error})
|
|
|
|
|
|
func _call_callback(callback: Callable, data: Dictionary) -> void:
|
|
if callback.is_valid():
|
|
callback.call(data)
|