fastsdk 0.2.30__tar.gz → 0.2.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {fastsdk-0.2.30 → fastsdk-0.2.32}/PKG-INFO +1 -1
  2. fastsdk-0.2.32/fastsdk/service_interaction/__init__.py +16 -0
  3. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/api_job_manager.py +137 -103
  4. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/api_seex.py +53 -43
  5. fastsdk-0.2.32/fastsdk/service_interaction/request/__init__.py +14 -0
  6. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/request/api_client.py +46 -84
  7. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/request/api_client_replicate.py +18 -6
  8. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/request/api_client_runpod.py +52 -10
  9. fastsdk-0.2.32/fastsdk/service_interaction/request/api_client_socaity.py +104 -0
  10. fastsdk-0.2.32/fastsdk/service_interaction/response/response_parser.py +207 -0
  11. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/response/response_parser_strategies.py +1 -1
  12. fastsdk-0.2.32/fastsdk/service_interaction/response/response_schemas.py +141 -0
  13. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk.egg-info/PKG-INFO +1 -1
  14. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk.egg-info/SOURCES.txt +1 -1
  15. {fastsdk-0.2.30 → fastsdk-0.2.32}/pyproject.toml +1 -1
  16. fastsdk-0.2.30/fastsdk/service_interaction/__init__.py +0 -5
  17. fastsdk-0.2.30/fastsdk/service_interaction/request/__init__.py +0 -7
  18. fastsdk-0.2.30/fastsdk/service_interaction/request/api_client_socaity.py +0 -41
  19. fastsdk-0.2.30/fastsdk/service_interaction/response/base_response.py +0 -77
  20. fastsdk-0.2.30/fastsdk/service_interaction/response/response_parser.py +0 -106
  21. {fastsdk-0.2.30 → fastsdk-0.2.32}/LICENSE +0 -0
  22. {fastsdk-0.2.30 → fastsdk-0.2.32}/README.md +0 -0
  23. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/__init__.py +0 -0
  24. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/fastClient.py +0 -0
  25. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/fastSDK.py +0 -0
  26. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/sdk_factory/__init__.py +0 -0
  27. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/sdk_factory/sdk_factory.py +0 -0
  28. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/sdk_factory/sdk_template.j2 +0 -0
  29. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/request/file_handler.py +0 -0
  30. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_interaction/response/api_job_status.py +0 -0
  31. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_specification_loader/runpod_open_api_loader.py +0 -0
  32. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk/service_specification_loader/spec_loader.py +0 -0
  33. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk.egg-info/dependency_links.txt +0 -0
  34. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk.egg-info/requires.txt +0 -0
  35. {fastsdk-0.2.30 → fastsdk-0.2.32}/fastsdk.egg-info/top_level.txt +0 -0
  36. {fastsdk-0.2.30 → fastsdk-0.2.32}/setup.cfg +0 -0
  37. {fastsdk-0.2.30 → fastsdk-0.2.32}/test/test_client_factory.py +0 -0
  38. {fastsdk-0.2.30 → fastsdk-0.2.32}/test/test_client_from_fastapi.py +0 -0
  39. {fastsdk-0.2.30 → fastsdk-0.2.32}/test/test_client_from_runpod_serverless.py +0 -0
  40. {fastsdk-0.2.30 → fastsdk-0.2.32}/test/test_httpx_api.py +0 -0
  41. {fastsdk-0.2.30 → fastsdk-0.2.32}/test/test_manual_sdk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastsdk
3
- Version: 0.2.30
3
+ Version: 0.2.32
4
4
  Summary: Your SDK and model zoo for generative AI. Build AI-powered applications with ease.
5
5
  Author: SocAIty
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -0,0 +1,16 @@
1
+ from .api_seex import APISeex
2
+ from .response.response_schemas import (
3
+ SocaityJobResponse, RunpodJobResponse, ReplicateJobResponse,
4
+ StreamingResponse, JOB_RESPONSE_TYPES,
5
+ )
6
+ from .api_job_manager import ApiJobManager
7
+
8
+ __all__ = [
9
+ "SocaityJobResponse",
10
+ "RunpodJobResponse",
11
+ "ReplicateJobResponse",
12
+ "StreamingResponse",
13
+ "JOB_RESPONSE_TYPES",
14
+ "APISeex",
15
+ "ApiJobManager",
16
+ ]
@@ -4,7 +4,10 @@ from typing import Any, Dict, Optional
4
4
 
5
5
  logger = logging.getLogger(__name__)
6
6
 
7
- from apipod_registry.definitions.service_definitions import ServiceDefinition, ServiceAddress, RunpodServiceAddress, ReplicateServiceAddress, SocaityServiceAddress, ServiceSpecification
7
+ from apipod_registry.definitions.service_definitions import (
8
+ ServiceDefinition, ServiceAddress,
9
+ RunpodServiceAddress, ReplicateServiceAddress, SocaityServiceAddress,
10
+ )
8
11
  from apipod_registry.registry import Registry
9
12
 
10
13
  from fastsdk.service_interaction.api_seex import APISeex
@@ -15,64 +18,92 @@ from fastsdk.service_interaction.request.file_handler import FileHandler
15
18
  from fastCloud import ReplicateUploadAPI
16
19
 
17
20
  from fastsdk.service_interaction.response.response_parser import ResponseParser
18
- from fastsdk.service_interaction.response.base_response import BaseJobResponse
21
+ from fastsdk.service_interaction.response.response_schemas import JOB_RESPONSE_TYPES, StreamingResponse
19
22
 
20
- from fastsdk.service_interaction.request import APIClient, APIClientReplicate, APIClientRunpod, APIClientSocaity, RequestData
23
+ from fastsdk.service_interaction.request import (
24
+ APIClient, APIClientReplicate, APIClientRunpod, APIClientSocaity, RequestData,
25
+ )
26
+ from fastsdk.service_interaction.request.api_client_runpod import APIClientRunpodApipod
21
27
  from fastsdk.service_interaction.response.api_job_status import APIJobStatus
22
28
  from media_toolkit import MediaDict
23
29
 
30
+
24
31
  class ApiJobManager:
25
- """
26
- Manages the lifecycle of asynchronous API jobs by orchestrating services.
27
- Delegates implementation details.
28
- """
32
+ """Manages the lifecycle of asynchronous API jobs by orchestrating services."""
33
+
34
+ _CLIENT_CLASSES = {
35
+ "runpod": APIClientRunpod,
36
+ "runpod_apipod": APIClientRunpodApipod,
37
+ "socaity": APIClientSocaity,
38
+ "replicate": APIClientReplicate,
39
+ }
40
+
29
41
  def __init__(self, service_registry: Registry, progress_verbosity: int = 2):
30
42
  self.service_registry = service_registry
31
43
  self.api_clients: Dict[str, APIClient] = {}
32
44
  self.file_handlers: Dict[str, FileHandler] = {}
33
- self.response_parser = ResponseParser()
45
+ self._provider_types: Dict[str, str] = {}
46
+ self._parser_cache: Dict[str, ResponseParser] = {}
34
47
  self.tasks = {
35
48
  "Preparing": self._prepare_request,
36
49
  "Load files": self._load_files,
37
50
  "Uploading files": self._upload_files,
38
51
  "Sending request": self._send_request,
39
52
  "Polling": self._poll_status,
40
- "Processing result": self._process_result
53
+ "Processing result": self._process_result,
41
54
  }
42
55
  self.meseex_box = MeseexBox(task_methods=self.tasks, progress_verbosity=progress_verbosity)
43
56
 
44
- def _determine_service_type(self, service_def: ServiceDefinition) -> ServiceSpecification:
45
- if isinstance(service_def.service_address, RunpodServiceAddress):
57
+ # ------------------------------------------------------------------
58
+ # Provider resolution & parser cache
59
+ # ------------------------------------------------------------------
60
+
61
+ @staticmethod
62
+ def _determine_service_type(service_def: ServiceDefinition) -> str:
63
+ addr = service_def.service_address
64
+ if isinstance(addr, RunpodServiceAddress):
65
+ if service_def.specification in ("apipod", "socaity"):
66
+ return "runpod_apipod"
46
67
  return "runpod"
47
- elif isinstance(service_def.service_address, SocaityServiceAddress):
68
+ if isinstance(addr, SocaityServiceAddress):
48
69
  return "socaity"
49
- elif isinstance(service_def.service_address, ReplicateServiceAddress):
70
+ if isinstance(addr, ReplicateServiceAddress):
50
71
  return "replicate"
51
- elif isinstance(service_def.service_address, ServiceAddress):
72
+ if isinstance(addr, ServiceAddress):
52
73
  if service_def.specification in ("apipod", "socaity"):
53
74
  return "socaity"
54
- elif service_def.specification == "runpod":
75
+ if service_def.specification == "runpod":
55
76
  return "runpod"
56
-
57
77
  return "other"
58
78
 
79
+ def _get_parser(self, service_id: str) -> ResponseParser:
80
+ provider = self._provider_types.get(service_id, "other")
81
+ if provider not in self._parser_cache:
82
+ self._parser_cache[provider] = ResponseParser(provider)
83
+ return self._parser_cache[provider]
84
+
85
+ # ------------------------------------------------------------------
86
+ # Client / handler registration
87
+ # ------------------------------------------------------------------
88
+
59
89
  def add_api_client(self, service_id: str, api_key: str):
60
- if service_id not in self.api_clients:
61
- service_def = self.service_registry.get_service(service_id)
62
- if not service_def:
63
- raise ValueError(f"Service {service_id} not found")
90
+ if service_id in self.api_clients:
91
+ return
64
92
 
65
- if not hasattr(service_def, "service_address") or service_def.service_address is None:
66
- raise ValueError(f"Service {service_id} has no service address. Add a service address to the service definition first with Registry.update_service(service_id, service_address=...)")
93
+ service_def = self.service_registry.get_service(service_id)
94
+ if not service_def:
95
+ raise ValueError(f"Service {service_id} not found")
96
+ if not hasattr(service_def, "service_address") or service_def.service_address is None:
97
+ raise ValueError(
98
+ f"Service {service_id} has no service address. "
99
+ "Add one with Registry.update_service(service_id, service_address=...)"
100
+ )
67
101
 
68
- service_type = self._determine_service_type(service_def)
69
- client_cls = {
70
- "runpod": APIClientRunpod,
71
- "socaity": APIClientSocaity,
72
- "replicate": APIClientReplicate,
73
- }.get(service_type, APIClient)
102
+ service_type = self._determine_service_type(service_def)
103
+ self._provider_types[service_id] = service_type
74
104
 
75
- self.api_clients[service_id] = client_cls(service_def=service_def, api_key=api_key)
105
+ client_cls = self._CLIENT_CLASSES.get(service_type, APIClient)
106
+ self.api_clients[service_id] = client_cls(service_def=service_def, api_key=api_key)
76
107
 
77
108
  def add_file_handler(self, service_id: str, api_key: str = None, file_handler: FileHandler = None):
78
109
  if file_handler is not None:
@@ -84,7 +115,7 @@ class ApiJobManager:
84
115
 
85
116
  if service_type == "socaity":
86
117
  file_handler = FileHandler(file_format="httpx", upload_to_cloud_threshold_mb=0, max_upload_file_size_mb=300)
87
- elif service_type == "runpod":
118
+ elif service_type in ("runpod", "runpod_apipod"):
88
119
  file_handler = FileHandler(file_format="base64", max_upload_file_size_mb=300)
89
120
  elif service_type == "replicate":
90
121
  fast_cloud = ReplicateUploadAPI(api_key=api_key)
@@ -103,6 +134,10 @@ class ApiJobManager:
103
134
  self.add_file_handler(service_def.id, api_key)
104
135
  return service_def
105
136
 
137
+ # ------------------------------------------------------------------
138
+ # Task implementations
139
+ # ------------------------------------------------------------------
140
+
106
141
  async def _prepare_request(self, job: APISeex) -> RequestData:
107
142
  api_client = self.api_clients[job.service_def.id]
108
143
  return api_client.format_request_params(job.endpoint_def, job.input)
@@ -111,7 +146,6 @@ class ApiJobManager:
111
146
  request_data = job.prev_task_output
112
147
  if not request_data.file_params:
113
148
  return request_data
114
-
115
149
  fh = self.file_handlers.get(job.service_def.id)
116
150
  request_data.file_params = await fh.load_files_from_disk(request_data.file_params)
117
151
  return request_data
@@ -120,7 +154,6 @@ class ApiJobManager:
120
154
  request_data = job.prev_task_output
121
155
  if not request_data.file_params:
122
156
  return request_data
123
-
124
157
  fh = self.file_handlers.get(job.service_def.id)
125
158
  request_data.file_params = await fh.upload_files(request_data.file_params)
126
159
  return request_data
@@ -128,6 +161,7 @@ class ApiJobManager:
128
161
  async def _send_request(self, job: APISeex) -> Any:
129
162
  request_data = job.prev_task_output
130
163
  api_client = self.api_clients[job.service_def.id]
164
+ parser = self._get_parser(job.service_def.id)
131
165
 
132
166
  if isinstance(request_data.file_params, MediaDict):
133
167
  non_file_params = request_data.file_params.get_non_file_params(include_urls=True)
@@ -136,26 +170,25 @@ class ApiJobManager:
136
170
 
137
171
  fh = self.file_handlers.get(job.service_def.id)
138
172
  request_data.file_params = await fh.prepare_files_for_send(request_data.file_params)
139
-
173
+
140
174
  logger.info("_send_request | Sending request to %s", request_data.url)
141
175
  response = await api_client.send_request(request_data)
142
-
143
- logger.info("_send_request | Received response: status=%d content_type=%s",
144
- response.status_code, response.headers.get("Content-Type"))
176
+ logger.info(
177
+ "_send_request | Received response: status=%d content_type=%s",
178
+ response.status_code, response.headers.get("Content-Type"),
179
+ )
145
180
 
146
- error = await self.response_parser.check_response_status(response)
181
+ error = await parser.check_response_status(response)
147
182
  if error:
148
183
  logger.error("_send_request | Request failed: %s", error)
149
184
  raise Exception(error)
150
185
 
151
- parsed = await self.response_parser.parse_response(response)
152
-
153
- # If it's a direct stream, store the raw response on the job for the forwarder
154
- if isinstance(parsed, BaseJobResponse) and parsed.status == APIJobStatus.STREAMING:
186
+ parsed = await parser.parse_response(response)
187
+
188
+ if isinstance(parsed, StreamingResponse):
155
189
  logger.info("_send_request | Detected direct stream response")
156
190
  job.direct_response = response
157
191
  else:
158
- # If not streaming, ensure response is closed after reading
159
192
  if not response.is_closed:
160
193
  await response.aclose()
161
194
 
@@ -166,78 +199,76 @@ class ApiJobManager:
166
199
  async def _poll_status(self, job: APISeex) -> Any:
167
200
  parsed_response = job.prev_task_output
168
201
 
169
- if not isinstance(parsed_response, BaseJobResponse):
170
- return parsed_response
171
-
172
- # Streaming responses are terminal from the SDK's perspective.
173
- # The worker's override handles forwarding chunks to Redis.
174
- if parsed_response.status == APIJobStatus.STREAMING:
202
+ if not isinstance(parsed_response, JOB_RESPONSE_TYPES):
175
203
  return parsed_response
176
204
 
177
205
  api_client = self.api_clients[job.service_def.id]
206
+ parser = self._get_parser(job.service_def.id)
178
207
 
179
208
  try:
180
209
  http_response = await api_client.poll_status(parsed_response)
181
210
  except Exception as e:
182
211
  n_errors = job.get_task_data() or {}
183
212
  n_polling_errors = n_errors.get("number_of_polling_errors", 0) if isinstance(n_errors, dict) else 0
184
-
185
213
  if n_polling_errors > 3:
186
214
  raise e
187
215
  job.set_task_data({"number_of_polling_errors": n_polling_errors + 1})
188
216
  return PollAgain(f"Job status polling failed: {e}")
189
217
 
190
- error = await self.response_parser.check_response_status(http_response)
218
+ error = await parser.check_response_status(http_response)
191
219
  if error:
192
220
  if not http_response.is_closed:
193
221
  await http_response.aclose()
194
222
  raise ValueError(f"Job status polling failed: {error}")
195
223
 
196
- parsed_response = await self.response_parser.parse_response(http_response, parse_media=False)
197
-
198
- # Ensure response is closed after reading
224
+ parsed_response = await parser.parse_response(http_response, parse_media=False)
225
+
199
226
  if not http_response.is_closed:
200
227
  await http_response.aclose()
201
228
 
202
- if not isinstance(parsed_response, BaseJobResponse):
229
+ if not isinstance(parsed_response, JOB_RESPONSE_TYPES):
203
230
  raise ValueError(f"Expected job response but got {type(parsed_response)}")
204
231
 
205
- if parsed_response.status == APIJobStatus.FINISHED:
232
+ status = api_client.get_status(parsed_response)
233
+
234
+ if status == APIJobStatus.FINISHED:
206
235
  return parsed_response
207
- elif parsed_response.status == APIJobStatus.CANCELLED:
236
+ if status == APIJobStatus.CANCELLED:
208
237
  job.mark_cancelled(cancel_result=parsed_response)
209
238
  return parsed_response
210
- elif parsed_response.status == APIJobStatus.FAILED:
211
- raise ValueError(parsed_response.error or f"Job failed with status: {parsed_response.status.value}")
239
+ if status == APIJobStatus.FAILED:
240
+ err = getattr(parsed_response, "error", None)
241
+ raise ValueError(err or f"Job failed with status: {getattr(parsed_response, 'status', 'unknown')}")
212
242
 
213
- progress_msg = f"Job {parsed_response.id}"
243
+ progress = getattr(parsed_response, "progress", None)
214
244
  message = getattr(parsed_response, "message", None)
215
- if message:
216
- progress_msg += f": {message}"
217
- else:
218
- progress_msg += f" status: {parsed_response.status.value}"
245
+ raw_status = getattr(parsed_response, "status", "unknown")
246
+
247
+ progress_msg = f"Job {getattr(parsed_response, 'id', getattr(parsed_response, 'job_id', '?'))}"
248
+ progress_msg += f": {message}" if message else f" status: {raw_status}"
219
249
 
220
- job.set_task_progress(parsed_response.progress, progress_msg)
250
+ job.set_task_progress(progress, progress_msg)
221
251
  job.set_task_output(parsed_response)
222
- return PollAgain(f"Job status: {parsed_response.status.value}")
252
+ return PollAgain(f"Job status: {raw_status}")
223
253
 
224
254
  async def _process_result(self, job: APISeex) -> Any:
225
- result = job.prev_task_output
226
- if not isinstance(result, BaseJobResponse):
227
- return result
255
+ response = job.prev_task_output
256
+
257
+ if isinstance(response, StreamingResponse):
258
+ return response
228
259
 
229
- # Streaming responses have no result body to parse — the
230
- # actual tokens were forwarded to the StreamStore by the
231
- # worker's _poll_status override. Return a marker so the
232
- # worker's _process_result knows the job was a stream.
233
- if result.status == APIJobStatus.STREAMING:
234
- return result
260
+ if not isinstance(response, JOB_RESPONSE_TYPES):
261
+ return response
262
+
263
+ api_client = self.api_clients[job.service_def.id]
264
+ parser = self._get_parser(job.service_def.id)
235
265
 
236
- result = await self.response_parser.parse_media_result(result)
237
- if result is None:
238
- return result
266
+ raw_result = api_client.get_result(response)
267
+ return parser.parse_media(raw_result)
239
268
 
240
- return result.result
269
+ # ------------------------------------------------------------------
270
+ # Job submission
271
+ # ------------------------------------------------------------------
241
272
 
242
273
  def submit_job(self, service_id: str, endpoint_id: str, data: dict) -> APISeex:
243
274
  service_def = self.service_registry.get_service(service_id)
@@ -276,60 +307,63 @@ class ApiJobManager:
276
307
  data=data,
277
308
  tasks=task_list,
278
309
  name=seex_name,
279
- cancel_handler=self.cancel_api_job
310
+ cancel_handler=self.cancel_api_job,
280
311
  )
312
+ job._meseex_box = self.meseex_box
313
+ job._api_client = self.api_clients[service_id]
314
+ job._response_parser = self._get_parser(service_id)
281
315
 
282
316
  return self.meseex_box.summon_meseex(job)
283
317
 
318
+ # ------------------------------------------------------------------
319
+ # Cancellation
320
+ # ------------------------------------------------------------------
321
+
284
322
  def _run_async_call(self, method, *args, timeout_s: float = 30.0):
285
323
  """Bridge helper: run an async method synchronously via the task executor."""
286
324
  task = self.meseex_box.task_executor.submit(method, *args)
287
325
  started_at = time.monotonic()
288
-
289
326
  while not task.is_completed:
290
327
  if timeout_s is not None and (time.monotonic() - started_at) > timeout_s:
291
328
  task.cancel()
292
329
  raise TimeoutError("Timed out while waiting for async call")
293
330
  time.sleep(0.01)
294
-
295
331
  if task.error is not None:
296
332
  raise task.error
297
-
298
333
  return task.result
299
334
 
300
- def _try_remote_cancel(self, job: APISeex) -> Optional[BaseJobResponse]:
335
+ def _try_remote_cancel(self, job: APISeex):
301
336
  """Best-effort: send a cancel request to the remote API."""
302
337
  current_response = job.response
303
- if not isinstance(current_response, BaseJobResponse) or not current_response.cancel_job_url:
338
+ api_client = self.api_clients[job.service_def.id]
339
+
340
+ if not isinstance(current_response, JOB_RESPONSE_TYPES) or not api_client.get_cancel_url(current_response):
304
341
  return None
305
342
 
306
- http_response = self._run_async_call(
307
- self.api_clients[job.service_def.id].cancel_job,
308
- current_response,
309
- )
310
- error = self._run_async_call(self.response_parser.check_response_status, http_response)
343
+ http_response = self._run_async_call(api_client.cancel_job, current_response)
344
+ parser = self._get_parser(job.service_def.id)
345
+
346
+ error = self._run_async_call(parser.check_response_status, http_response)
311
347
  if error:
312
- parsed_error = self._run_async_call(self.response_parser.parse_response, http_response, False)
348
+ parsed_error = self._run_async_call(parser.parse_response, http_response, False)
313
349
  if not http_response.is_closed:
314
350
  self._run_async_call(http_response.aclose)
315
- if isinstance(parsed_error, BaseJobResponse):
351
+ if isinstance(parsed_error, JOB_RESPONSE_TYPES):
316
352
  return parsed_error
317
353
  raise ValueError(f"Remote cancellation failed with HTTP error: {error}")
318
354
 
319
- parsed = self._run_async_call(self.response_parser.parse_response, http_response, False)
355
+ parsed = self._run_async_call(parser.parse_response, http_response, False)
320
356
  if not http_response.is_closed:
321
357
  self._run_async_call(http_response.aclose)
322
- return parsed if isinstance(parsed, BaseJobResponse) else None
358
+ return parsed if isinstance(parsed, JOB_RESPONSE_TYPES) else None
323
359
 
324
360
  def cancel_api_job(self, job: APISeex, **kwargs) -> Any:
325
361
  """Best-effort cancel: send remote cancel request if possible."""
326
- if not isinstance(job.response, BaseJobResponse) or not job.response.cancel_job_url:
327
- local_cancel = BaseJobResponse(
328
- id=job.meseex_id,
329
- status=APIJobStatus.CANCELLED,
330
- error="Cancelled before remote submission",
331
- service_specification=job.service_def.specification,
332
- )
362
+ api_client = self.api_clients.get(job.service_def.id)
363
+ current = job.response
364
+
365
+ if not isinstance(current, JOB_RESPONSE_TYPES) or not api_client or not api_client.get_cancel_url(current):
366
+ local_cancel = {"id": job.meseex_id, "status": "CANCELLED", "error": "Cancelled before remote submission"}
333
367
  self.meseex_box.cancel_meseex(job, cancel_result=local_cancel)
334
368
  return local_cancel
335
369
 
@@ -337,13 +371,13 @@ class ApiJobManager:
337
371
  remote_response = self._try_remote_cancel(job)
338
372
  except Exception as e:
339
373
  print(f"Warning: Remote cancellation for job {job.meseex_id} failed: {e}. Job will continue polling.")
340
- return job.response
374
+ return current
341
375
 
342
376
  if remote_response is None:
343
377
  print(f"Warning: Job {job.meseex_id} has no remote cancel URL or no job response. Job will continue polling.")
344
- return job.response
378
+ return current
345
379
 
346
- if remote_response.status == APIJobStatus.CANCELLED:
380
+ if api_client.get_status(remote_response) == APIJobStatus.CANCELLED:
347
381
  self.meseex_box.cancel_meseex(job, cancel_result=remote_response)
348
382
  return remote_response
349
383