Skip to content

Commit 4a1fb83

Browse files
committed
fix: make AsyncOgxClient functional by inheriting from sync ApiClient
The AsyncOgxClient was completely broken because AsyncApiClient was a standalone class missing 15+ methods (select_header_accept, param_serialize, call_api, response_deserialize, etc.) that the generated API classes depend on. Any call through AsyncOgxClient would immediately crash with AttributeError. Additionally, all API methods were synchronous — they could not be awaited. Root causes: - AsyncApiClient reimplemented only HTTP transport but omitted all serialization, deserialization, and parameter handling methods - Generated API classes (e.g. InspectApi) were shared between sync and async clients but only had sync method signatures - Nested API wiring (e.g. client.chat.completions) was only applied to OgxClient, not AsyncOgxClient Fix AsyncApiClient by making it inherit from ApiClient, overriding only __init__ (creates httpx.AsyncClient), call_api (async HTTP transport), close (async cleanup), and context managers. All serialization and deserialization methods are inherited unchanged. Generate proper async API subclasses (e.g. AsyncInspectApi) in the same file as their sync counterparts via the api.mustache template. These inherit the _serialize methods and override the three operation method variants to use async def with await on call_api and aread. Update AsyncOgxClient to wire Async*Api classes instead of sync ones, and fix patch_hierarchy.py to apply nested API wiring to both OgxClient and AsyncOgxClient. Add RESTResponse.aread() for async response body reading, and update all export templates to include the new Async*Api classes.
1 parent a84aa6b commit 4a1fb83

8 files changed

Lines changed: 379 additions & 141 deletions

File tree

client-sdks/openapi/patch_hierarchy.py

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,13 @@ def patch_optional_import(api_file: Path) -> bool:
152152

153153

154154
def patch_ogx_client(client_file: Path, pairs: list[tuple[str, str]]) -> bool:
155-
"""Patch OgxClient to wire up parent-child API relationships.
155+
"""Patch OgxClient and AsyncOgxClient to wire up parent-child API relationships.
156156
157-
Looks for the '# Nested API structure' comment and inserts assignments like:
157+
Looks for all '# Nested API structure' comment anchors and inserts assignments like:
158158
self.chat.completions = self.completions
159159
160+
This handles both OgxClient and AsyncOgxClient in the same file.
161+
160162
Returns True if the file was modified.
161163
"""
162164
if not client_file.exists():
@@ -166,14 +168,13 @@ def patch_ogx_client(client_file: Path, pairs: list[tuple[str, str]]) -> bool:
166168
with open(client_file) as f:
167169
lines = f.readlines()
168170

169-
# Find the anchor comment
170-
comment_idx = None
171+
# Find all anchor comment indices
172+
comment_indices = []
171173
for i, line in enumerate(lines):
172174
if "# Nested API structure" in line:
173-
comment_idx = i
174-
break
175+
comment_indices.append(i)
175176

176-
if comment_idx is None:
177+
if not comment_indices:
177178
print(f" Warning: '# Nested API structure' comment not found in {client_file}")
178179
return False
179180

@@ -185,30 +186,32 @@ def patch_ogx_client(client_file: Path, pairs: list[tuple[str, str]]) -> bool:
185186
if any(test_line in line for line in lines):
186187
return False
187188

188-
comment_line = lines[comment_idx]
189-
indent = len(comment_line) - len(comment_line.lstrip())
190-
191-
patch_lines = [f"{' ' * indent}# Wire up parent-child API relationships\n"]
192-
for parent_tag, child_tag in pairs:
193-
parent_snake = to_snake_case(parent_tag)
194-
child_snake = to_snake_case(child_tag)
195-
patch_lines.append(f"{' ' * indent}self.{parent_snake}.{child_snake} = self.{child_snake}\n")
196-
# Also add a short alias if the child name is prefixed with the parent name
197-
# e.g., chat_completions -> chat.completions
198-
if child_snake.startswith(f"{parent_snake}_"):
199-
subresource_name = child_snake.removeprefix(f"{parent_snake}_")
200-
patch_lines.append(
201-
f"{' ' * indent}self.{parent_snake}.__dict__['{subresource_name}'] = self.{child_snake}\n"
202-
)
203-
204-
insert_idx = comment_idx + 1
205-
for line in reversed(patch_lines):
206-
lines.insert(insert_idx, line)
189+
# Patch each anchor (process in reverse so line indices remain valid)
190+
for comment_idx in reversed(comment_indices):
191+
comment_line = lines[comment_idx]
192+
indent = len(comment_line) - len(comment_line.lstrip())
193+
194+
patch_lines = [f"{' ' * indent}# Wire up parent-child API relationships\n"]
195+
for parent_tag, child_tag in pairs:
196+
parent_snake = to_snake_case(parent_tag)
197+
child_snake = to_snake_case(child_tag)
198+
patch_lines.append(f"{' ' * indent}self.{parent_snake}.{child_snake} = self.{child_snake}\n")
199+
# Also add a short alias if the child name is prefixed with the parent name
200+
# e.g., chat_completions -> chat.completions
201+
if child_snake.startswith(f"{parent_snake}_"):
202+
subresource_name = child_snake.removeprefix(f"{parent_snake}_")
203+
patch_lines.append(
204+
f"{' ' * indent}self.{parent_snake}.__dict__['{subresource_name}'] = self.{child_snake}\n"
205+
)
206+
207+
insert_idx = comment_idx + 1
208+
for line in reversed(patch_lines):
209+
lines.insert(insert_idx, line)
207210

208211
with open(client_file, "w") as f:
209212
f.writelines(lines)
210213

211-
print(f" Patched OgxClient with {len(pairs)} parent-child relationships")
214+
print(f" Patched OgxClient and AsyncOgxClient with {len(pairs)} parent-child relationships each")
212215
return True
213216

214217

client-sdks/openapi/templates/python/__init__package.mustache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ __all__ = [
2929
"OgxClient",
3030
"AsyncOgxClient",
3131
{{#apiInfo}}{{#apis}}"{{classname}}",
32+
"Async{{classname}}",
3233
{{/apis}}{{/apiInfo}}"ApiResponse",
3334
"APIResponse",
3435
"ApiClient",

client-sdks/openapi/templates/python/api.mustache

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ from typing_extensions import Annotated
1818
{{/imports}}
1919

2020
from {{packageName}}.api_client import ApiClient, RequestSerialized
21+
from {{packageName}}.async_api_client import AsyncApiClient
2122
from {{packageName}}.api_response import ApiResponse
2223
from {{packageName}}.rest import RESTResponseType
2324
from {{packageName}}.stream import Stream
25+
from {{packageName}}.async_stream import AsyncStream
2426
from {{packageName}}.lib._utils import pascal_to_snake_case
2527

2628

@@ -339,5 +341,148 @@ class {{classname}}:
339341
{{alias}} = {{target}}
340342
{{/vendorExtensions.x-operation-aliases}}
341343

344+
{{/operation}}
345+
346+
347+
class Async{{classname}}({{classname}}):
348+
"""Async version of {{classname}}.
349+
350+
Inherits serialization and deserialization from {{classname}}.
351+
Overrides HTTP methods to use async/await with AsyncApiClient.
352+
"""
353+
354+
def __init__(self, api_client=None) -> None:
355+
if api_client is None:
356+
api_client = AsyncApiClient.get_default()
357+
self.api_client = api_client
358+
self.logger = logging.getLogger(Async{{classname}}.__name__)
359+
360+
# Child API attributes (set by AsyncOgxClient based on x-nesting-path)
361+
{{#operation}}{{#vendorExtensions.x-nesting-path}}{{#-first}}{{#tags}}{{#-first}}{{#vendorExtensions.x-nesting-path}}{{^-last}} self.{{.}}: Any | None = None # Next in nesting path
362+
{{/-last}}{{/vendorExtensions.x-nesting-path}}{{/-first}}{{/tags}}{{/-first}}{{/vendorExtensions.x-nesting-path}}{{/operation}}
363+
364+
def _create_event_stream(self, response_data: RESTResponseType, stream_type_name: str) -> AsyncStream[Any]:
365+
"""Create a typed async event stream from an SSE response.
366+
367+
Args:
368+
response_data: The raw HTTP response containing the SSE stream.
369+
stream_type_name: The schema name for the streaming response type.
370+
"""
371+
stream_class = None
372+
discriminator_map = None
373+
discriminator_property = None
374+
try:
375+
module_name = pascal_to_snake_case(stream_type_name)
376+
model_module = importlib.import_module('{{packageName}}.models.' + module_name)
377+
stream_class = getattr(model_module, stream_type_name)
378+
discriminator_map = getattr(stream_class, 'discriminator_value_class_map', None)
379+
discriminator_property = getattr(stream_class, 'discriminator_property_name', None)
380+
except (ImportError, ModuleNotFoundError, AttributeError) as e:
381+
self.logger.debug(f"Could not import stream type {stream_type_name}: {e}")
382+
383+
def stream_decoder(data_str: str) -> Any:
384+
if not data_str:
385+
return None
386+
try:
387+
data = json.loads(data_str)
388+
except json.JSONDecodeError:
389+
return data_str
390+
391+
if discriminator_map and discriminator_property:
392+
disc_value = data.get(discriminator_property, '')
393+
variant_class_name = discriminator_map.get(disc_value)
394+
if variant_class_name:
395+
try:
396+
variant_module_name = pascal_to_snake_case(variant_class_name)
397+
variant_module = importlib.import_module('{{packageName}}.models.' + variant_module_name)
398+
variant_class = getattr(variant_module, variant_class_name)
399+
return variant_class.from_dict(data)
400+
except Exception as e:
401+
self.logger.debug(f"Failed to deserialize as {variant_class_name}: {e}")
402+
403+
if stream_class is not None:
404+
try:
405+
result = stream_class.from_dict(data)
406+
if hasattr(result, 'actual_instance') and result.actual_instance is not None:
407+
return result.actual_instance
408+
return result
409+
except Exception as e:
410+
self.logger.debug(f"Failed to deserialize as {stream_type_name}: {e}")
411+
412+
return data
413+
414+
return AsyncStream(
415+
response=response_data.response,
416+
client=self.api_client,
417+
decoder=stream_decoder,
418+
)
419+
420+
{{#operation}}
421+
422+
@validate_call
423+
async def {{#vendorExtensions.x-operation-name}}{{vendorExtensions.x-operation-name}}{{/vendorExtensions.x-operation-name}}{{^vendorExtensions.x-operation-name}}{{operationId}}{{/vendorExtensions.x-operation-name}}{{>partial_api_args}} -> {{#vendorExtensions.x-streaming}}{{#vendorExtensions.x-unwrap-list-response}}{{#returnType}}{{#returnContainer}}{{{returnBaseType}}} | AsyncStream[{{{returnBaseType}}}]{{/returnContainer}}{{^returnContainer}}{{{returnType}}} | AsyncStream[{{{returnType}}}]{{/returnContainer}}{{/returnType}}{{^returnType}}None | AsyncStream[None]{{/returnType}}{{/vendorExtensions.x-unwrap-list-response}}{{^vendorExtensions.x-unwrap-list-response}}{{{returnType}}}{{^returnType}}None{{/returnType}} | AsyncStream[{{{returnType}}}{{^returnType}}None{{/returnType}}]{{/vendorExtensions.x-unwrap-list-response}}{{/vendorExtensions.x-streaming}}{{^vendorExtensions.x-streaming}}{{#vendorExtensions.x-unwrap-list-response}}{{#returnType}}{{#returnContainer}}{{{returnBaseType}}}{{/returnContainer}}{{^returnContainer}}{{{returnType}}}{{/returnContainer}}{{/returnType}}{{^returnType}}None{{/returnType}}{{/vendorExtensions.x-unwrap-list-response}}{{^vendorExtensions.x-unwrap-list-response}}{{{returnType}}}{{^returnType}}None{{/returnType}}{{/vendorExtensions.x-unwrap-list-response}}{{/vendorExtensions.x-streaming}}:
424+
{{>partial_api}}
425+
426+
response_data = await self.api_client.call_api(
427+
*_param,
428+
_request_timeout=_request_timeout
429+
)
430+
431+
{{#vendorExtensions.x-streaming}}
432+
# Check if this is a streaming response
433+
content_type = response_data.response.headers.get('Content-Type', '')
434+
if 'text/event-stream' in content_type:
435+
return self._create_event_stream(response_data, "{{vendorExtensions.x-streaming-type}}")
436+
437+
{{/vendorExtensions.x-streaming}}
438+
await response_data.aread()
439+
_deserialized = self.api_client.response_deserialize(
440+
response_data=response_data,
441+
response_types_map=_response_types_map,
442+
).data
443+
# Unwrap List*Response wrappers to return the data field directly
444+
{{#vendorExtensions.x-unwrap-list-response}}
445+
if _deserialized is not None and hasattr(_deserialized, 'data'):
446+
return _deserialized.data
447+
{{/vendorExtensions.x-unwrap-list-response}}
448+
return _deserialized
449+
450+
@validate_call
451+
async def {{#vendorExtensions.x-operation-name}}{{vendorExtensions.x-operation-name}}{{/vendorExtensions.x-operation-name}}{{^vendorExtensions.x-operation-name}}{{operationId}}{{/vendorExtensions.x-operation-name}}_with_http_info{{>partial_api_args}} -> {{#vendorExtensions.x-streaming}}ApiResponse[{{{returnType}}}{{^returnType}}None{{/returnType}}] | AsyncStream[{{{returnType}}}{{^returnType}}None{{/returnType}}]{{/vendorExtensions.x-streaming}}{{^vendorExtensions.x-streaming}}ApiResponse[{{{returnType}}}{{^returnType}}None{{/returnType}}]{{/vendorExtensions.x-streaming}}:
452+
{{>partial_api}}
453+
454+
response_data = await self.api_client.call_api(
455+
*_param,
456+
_request_timeout=_request_timeout
457+
)
458+
459+
{{#vendorExtensions.x-streaming}}
460+
# Check if this is a streaming response
461+
content_type = response_data.response.headers.get('Content-Type', '')
462+
if 'text/event-stream' in content_type:
463+
return self._create_event_stream(response_data, "{{vendorExtensions.x-streaming-type}}")
464+
465+
{{/vendorExtensions.x-streaming}}
466+
await response_data.aread()
467+
return self.api_client.response_deserialize(
468+
response_data=response_data,
469+
response_types_map=_response_types_map,
470+
)
471+
472+
@validate_call
473+
async def {{#vendorExtensions.x-operation-name}}{{vendorExtensions.x-operation-name}}{{/vendorExtensions.x-operation-name}}{{^vendorExtensions.x-operation-name}}{{operationId}}{{/vendorExtensions.x-operation-name}}_without_preload_content{{>partial_api_args}} -> RESTResponseType:
474+
{{>partial_api}}
475+
476+
response_data = await self.api_client.call_api(
477+
*_param,
478+
_request_timeout=_request_timeout
479+
)
480+
return response_data.response
481+
482+
483+
{{#vendorExtensions.x-operation-aliases}}
484+
{{alias}} = {{target}}
485+
{{/vendorExtensions.x-operation-aliases}}
486+
342487
{{/operation}}
343488
{{/operations}}

0 commit comments

Comments
 (0)