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)