licos-dev-sdk 0.2.9__tar.gz → 0.2.11__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 (20) hide show
  1. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/PKG-INFO +1 -1
  2. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/pyproject.toml +1 -1
  3. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/model.py +303 -42
  4. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/tests/test_model.py +134 -16
  5. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/.gitignore +0 -0
  6. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/__init__.py +0 -0
  7. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/_utils.py +0 -0
  8. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/archive.py +0 -0
  9. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/chart.py +0 -0
  10. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/data.py +0 -0
  11. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/diagram.py +0 -0
  12. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/document.py +0 -0
  13. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/image.py +0 -0
  14. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/observability.py +0 -0
  15. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/presentation.py +0 -0
  16. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/spreadsheet.py +0 -0
  17. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/src/licos_dev_sdk/web.py +0 -0
  18. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/tests/test_document_spreadsheet.py +0 -0
  19. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/tests/test_observability.py +0 -0
  20. {licos_dev_sdk-0.2.9 → licos_dev_sdk-0.2.11}/tests/test_output_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: LICOS Dev SDK - file generation and model capability clients
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: docxtpl>=0.16
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-sdk"
7
- version = "0.2.9"
7
+ version = "0.2.11"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -23,9 +23,11 @@ MODEL_DETAIL_PATH = "/api/v1/admin/workspaces/models/detail"
23
23
  DEFAULT_REQUEST_TIMEOUT_SECS = 120
24
24
  DEFAULT_ASYNC_TIMEOUT_SECS = 600
25
25
  DEFAULT_ASYNC_POLL_INTERVAL_SECS = 2.0
26
+ ASYNC_MODEL_TASK_HEADER_NAME = "X-DashScope-Async"
27
+ ASYNC_MODEL_TASK_HEADER_VALUE = "enable"
26
28
  DEFAULT_CATALOG_CACHE_TTL_SECS = 300
27
29
 
28
- _CATALOG_CACHE: dict[tuple[str, str, str, str, str], tuple[float, list[dict[str, Any]]]] = {}
30
+ _CATALOG_CACHE: dict[tuple[str, str, str, str, str, str], tuple[float, list[dict[str, Any]]]] = {}
29
31
  _DETAIL_CACHE: dict[tuple[str, str, str, str], tuple[float, dict[str, Any] | None]] = {}
30
32
 
31
33
 
@@ -35,6 +37,13 @@ class ModelRuntime:
35
37
  token: str
36
38
  user_id: str | None = None
37
39
  workspace_id: str | None = None
40
+ owner_project_id: str | None = None
41
+ owner_project_type: str | None = None
42
+ owner_application_type: str | None = None
43
+ owner_org_id: str | None = None
44
+ runtime_env: str | None = None
45
+ current_user_id: str | None = None
46
+ current_org_id: str | None = None
38
47
 
39
48
 
40
49
  @dataclass(frozen=True)
@@ -44,6 +53,7 @@ class ModelEndpoint:
44
53
  base_url: str
45
54
  model: str
46
55
  required_headers: dict[str, str] = field(default_factory=dict)
56
+ request_headers: dict[str, str] = field(default_factory=dict)
47
57
  endpoint: dict[str, Any] = field(default_factory=dict)
48
58
  response_url: str | None = None
49
59
  cache_context: bool = False
@@ -173,7 +183,7 @@ def create_chat_openai(
173
183
 
174
184
  runtime = _model_runtime(base_url=base_url, user_token=user_token, user_id=user_id, workspace_id=workspace_id)
175
185
  endpoint = _resolve_chat_endpoint(runtime, model_group=model_group, requested_model=model)
176
- headers = {**endpoint.required_headers, **(default_headers or {})}
186
+ headers = {**_request_headers(endpoint), **(default_headers or {})}
177
187
  output_tokens = max_completion_tokens if max_completion_tokens is not None else max_tokens
178
188
  chat_kwargs: dict[str, Any] = {
179
189
  "model": endpoint.model,
@@ -408,21 +418,21 @@ class ImageGenerationClient:
408
418
  ) -> ModelResult:
409
419
  endpoint = _resolve_endpoint_with_detail(self.runtime, "imageGeneration", requested_model=model)
410
420
  selected_model = endpoint.model
411
- if raw_request:
412
- body = dict(raw_request)
413
- body.setdefault("model", selected_model)
414
- else:
415
- params = dict(parameters or {})
416
- params["n"] = _image_count(count, params)
417
- if size:
418
- params["size"] = size
419
- input_payload = {"prompt": prompt}
420
- if negative_prompt:
421
- input_payload["negative_prompt"] = negative_prompt
422
- body = {"model": selected_model, "input": input_payload, "parameters": params}
423
- return _submit_model_task(
424
- endpoint,
425
- self.runtime,
421
+ if raw_request:
422
+ body = dict(raw_request)
423
+ body.setdefault("model", selected_model)
424
+ else:
425
+ body = _image_generation_body(
426
+ prompt,
427
+ selected_model,
428
+ count=count,
429
+ negative_prompt=negative_prompt,
430
+ size=size,
431
+ parameters=parameters,
432
+ )
433
+ return _submit_model_task(
434
+ endpoint,
435
+ self.runtime,
426
436
  body,
427
437
  wait=wait,
428
438
  timeout=timeout,
@@ -577,17 +587,105 @@ def _model_runtime(
577
587
  owner_user_id = user_id or env("LICOS_USER_ID") or env("AGENT_USER_ID")
578
588
  resolved_workspace_id = _workspace_id(workspace_id)
579
589
  token = (user_token or "").strip() or resolve_user_token(resolved_base_url, owner_user_id)
590
+ current_user_id = env("LICOS_CURRENT_USER_ID") or env("AGENT_CURRENT_USER_ID")
591
+ current_org_id = (
592
+ env("LICOS_CURRENT_ORG_ID")
593
+ or env("AGENT_CURRENT_ORG_ID")
594
+ or env("LICOS_CURRENT_TENANT_ID")
595
+ or env("AGENT_CURRENT_TENANT_ID")
596
+ )
597
+ if not current_user_id or not current_org_id:
598
+ current_user_id, current_org_id = _fetch_current_user_context(resolved_base_url, token)
599
+ owner_project_id = env("LICOS_PROJECT_ID") or env("AGENT_PROJECT_ID")
600
+ owner_project_type = (
601
+ env("LICOS_OWNER_PROJECT_TYPE")
602
+ or env("AGENT_OWNER_PROJECT_TYPE")
603
+ or env("LICOS_PROJECT_TYPE")
604
+ or env("AGENT_PROJECT_TYPE")
605
+ )
606
+ owner_application_type = (
607
+ env("LICOS_OWNER_APPLICATION_TYPE")
608
+ or env("AGENT_OWNER_APPLICATION_TYPE")
609
+ or env("LICOS_APPLICATION_TYPE")
610
+ or env("AGENT_APPLICATION_TYPE")
611
+ or env("LICOS_OWNER_APPLIACTION_TYPE")
612
+ or env("AGENT_OWNER_APPLIACTION_TYPE")
613
+ )
614
+ owner_org_id = (
615
+ env("LICOS_OWNER_ORG_ID")
616
+ or env("AGENT_OWNER_ORG_ID")
617
+ or env("LICOS_TENANT_ID")
618
+ or env("AGENT_TENANT_ID")
619
+ or env("LICOS_ORG_ID")
620
+ or env("AGENT_ORG_ID")
621
+ )
622
+ runtime_env = env("LICOS_RUNTIME_ENV") or env("AGENT_RUNTIME_ENV")
623
+ if owner_project_id:
624
+ if not resolved_workspace_id:
625
+ raise ConfigurationError("project owner workspace ID is not configured")
626
+ if not owner_user_id:
627
+ raise ConfigurationError("project owner user ID is not configured")
628
+ if not runtime_env:
629
+ raise ConfigurationError("project runtime environment is not configured")
630
+ if not owner_org_id:
631
+ owner_org_id = _fetch_workspace_owner_org_id(resolved_base_url, token, resolved_workspace_id)
580
632
  return ModelRuntime(
581
633
  base_url=resolved_base_url,
582
634
  token=token,
583
635
  user_id=owner_user_id,
584
636
  workspace_id=resolved_workspace_id,
637
+ owner_project_id=owner_project_id,
638
+ owner_project_type=owner_project_type,
639
+ owner_application_type=owner_application_type,
640
+ owner_org_id=owner_org_id,
641
+ runtime_env=runtime_env,
642
+ current_user_id=current_user_id,
643
+ current_org_id=current_org_id,
585
644
  )
586
645
 
587
646
 
588
647
  def _refresh_model_runtime(runtime: ModelRuntime) -> ModelRuntime:
589
648
  token = resolve_user_token(runtime.base_url, runtime.user_id, force_refresh=True)
590
- return replace(runtime, token=token)
649
+ current_user_id = runtime.current_user_id
650
+ current_org_id = runtime.current_org_id
651
+ if not current_user_id or not current_org_id:
652
+ current_user_id, current_org_id = _fetch_current_user_context(runtime.base_url, token)
653
+ return replace(runtime, token=token, current_user_id=current_user_id, current_org_id=current_org_id)
654
+
655
+
656
+ def _fetch_current_user_context(base_url: str, token: str) -> tuple[str, str]:
657
+ payload = _request_json(
658
+ "GET",
659
+ f"{base_url}/api/v1/admin/auth/me",
660
+ token=token,
661
+ timeout=30,
662
+ )
663
+ data = payload.get("data") if isinstance(payload, dict) else None
664
+ if not isinstance(data, dict):
665
+ data = payload if isinstance(payload, dict) else {}
666
+ user_id = _first_non_empty_field(data, ["id", "userId", "user_id", "sub"])
667
+ org_id = _first_non_empty_field(data, ["tenantId", "tenant_id"])
668
+ if not user_id:
669
+ raise ApiError("current user response missing id", details=payload)
670
+ if not org_id:
671
+ raise ApiError("current user response missing tenantId", details=payload)
672
+ return user_id, org_id
673
+
674
+
675
+ def _fetch_workspace_owner_org_id(base_url: str, token: str, workspace_id: str) -> str:
676
+ payload = _request_json(
677
+ "GET",
678
+ f"{base_url}/api/v1/admin/workspaces/{parse.quote(workspace_id, safe='')}",
679
+ token=token,
680
+ timeout=30,
681
+ )
682
+ data = payload.get("data") if isinstance(payload, dict) else None
683
+ if not isinstance(data, dict):
684
+ data = payload if isinstance(payload, dict) else {}
685
+ owner_org_id = _first_non_empty_field(data, ["tenantId", "tenant_id"])
686
+ if not owner_org_id:
687
+ raise ApiError("owner workspace response missing tenantId", details=payload)
688
+ return owner_org_id
591
689
 
592
690
 
593
691
  def _fetch_model_catalogs(
@@ -609,6 +707,7 @@ def _fetch_model_catalogs(
609
707
  resolved_workspace_id,
610
708
  category_code,
611
709
  input_capabilities or "",
710
+ _routing_cache_fingerprint(runtime),
612
711
  )
613
712
  ttl = _int_env("LICOS_MODEL_CATALOG_CACHE_TTL_SECS", DEFAULT_CATALOG_CACHE_TTL_SECS)
614
713
  cached = _CATALOG_CACHE.get(cache_key)
@@ -621,13 +720,33 @@ def _fetch_model_catalogs(
621
720
  query = {"categoryCode": category_code}
622
721
  if input_capabilities:
623
722
  query["inputCapabilities"] = input_capabilities
723
+ if runtime.owner_project_id:
724
+ query["projectId"] = runtime.owner_project_id
725
+ if runtime.owner_project_type:
726
+ query["ownerProjectType"] = runtime.owner_project_type
727
+ if runtime.owner_application_type:
728
+ query["ownerApplicationType"] = runtime.owner_application_type
729
+ if runtime.workspace_id:
730
+ query["ownerWorkspaceId"] = runtime.workspace_id
731
+ if runtime.owner_org_id:
732
+ query["tenantId"] = runtime.owner_org_id
733
+ query["ownerOrgId"] = runtime.owner_org_id
734
+ if runtime.user_id:
735
+ query["ownerUserId"] = runtime.user_id
736
+ if runtime.runtime_env:
737
+ query["runtimeEnv"] = runtime.runtime_env
738
+ if runtime.current_user_id:
739
+ query["currentUserId"] = runtime.current_user_id
740
+ if runtime.current_org_id:
741
+ query["currentOrgId"] = runtime.current_org_id
742
+ query["currentTenantId"] = runtime.current_org_id
624
743
  url = f"{runtime.base_url}{path}?{parse.urlencode(query)}"
625
744
  try:
626
745
  payload = _request_json(
627
746
  "GET",
628
747
  url,
629
748
  token=runtime.token,
630
- headers={"X-Workspace-Id": resolved_workspace_id},
749
+ headers={**_routing_headers(runtime), "X-Workspace-Id": resolved_workspace_id},
631
750
  timeout=30,
632
751
  )
633
752
  except ApiError as exc:
@@ -655,6 +774,85 @@ def _workspace_id(workspace_id: str | None = None) -> str | None:
655
774
  return value or None
656
775
 
657
776
 
777
+ def _routing_cache_fingerprint(runtime: ModelRuntime) -> str:
778
+ pairs = [
779
+ ("op", runtime.owner_project_id),
780
+ ("opt", runtime.owner_project_type),
781
+ ("oat", runtime.owner_application_type),
782
+ ("ow", runtime.workspace_id),
783
+ ("oo", runtime.owner_org_id),
784
+ ("ou", runtime.user_id),
785
+ ("re", runtime.runtime_env),
786
+ ("cu", runtime.current_user_id),
787
+ ("co", runtime.current_org_id),
788
+ ]
789
+ return "|".join(f"{key}={value}" for key, value in pairs if value)
790
+
791
+
792
+ def _routing_headers(runtime: ModelRuntime) -> dict[str, str]:
793
+ headers: dict[str, str] = {}
794
+
795
+ def add(name: str, value: str | None) -> None:
796
+ if value and str(value).strip():
797
+ headers[name] = str(value).strip()
798
+
799
+ add("X-Licos-Current-User-Id", runtime.current_user_id)
800
+ add("X-Licos-Current-Org-Id", runtime.current_org_id)
801
+ add("X-Licos-Owner-Project-Id", runtime.owner_project_id)
802
+ add("X-Licos-Owner-Project-Type", runtime.owner_project_type)
803
+ add("X-Licos-Owner-Application-Type", runtime.owner_application_type)
804
+ add("X-Licos-Owner-Workspace-Id", runtime.workspace_id)
805
+ add("X-Licos-Owner-Org-Id", runtime.owner_org_id)
806
+ add("X-Licos-Owner-User-Id", runtime.user_id)
807
+ add("X-Licos-Runtime-Env", runtime.runtime_env)
808
+ add("X-Project-Id", runtime.owner_project_id)
809
+ add("X-Project-Type", runtime.owner_project_type)
810
+ add("X-Application-Type", runtime.owner_application_type)
811
+ add("X-Workspace-Id", runtime.workspace_id)
812
+ add("X-Tenant-Id", runtime.owner_org_id)
813
+ add("X-User-Id", runtime.user_id)
814
+ add("X-Runtime-Env", runtime.runtime_env)
815
+ return headers
816
+
817
+
818
+ def _request_headers(endpoint: ModelEndpoint) -> dict[str, str]:
819
+ if endpoint.request_headers:
820
+ return endpoint.request_headers
821
+ return endpoint.required_headers
822
+
823
+
824
+ def _task_submit_headers(endpoint: ModelEndpoint) -> dict[str, str]:
825
+ headers = dict(_request_headers(endpoint))
826
+ if _is_async_model_task(endpoint) and not any(
827
+ key.lower() == ASYNC_MODEL_TASK_HEADER_NAME.lower() for key in headers
828
+ ):
829
+ headers[ASYNC_MODEL_TASK_HEADER_NAME] = ASYNC_MODEL_TASK_HEADER_VALUE
830
+ return headers
831
+
832
+
833
+ def _is_async_model_task(endpoint: ModelEndpoint) -> bool:
834
+ if endpoint.capability.strip().lower() in {"imagegeneration", "image_generation"}:
835
+ return False
836
+ return (
837
+ endpoint.async_task
838
+ or bool(endpoint.task_query_url)
839
+ or any(key.lower() == ASYNC_MODEL_TASK_HEADER_NAME.lower() for key in endpoint.request_headers)
840
+ or _is_async_model_task_capability(endpoint.capability)
841
+ )
842
+
843
+
844
+ def _is_async_model_task_capability(capability: str) -> bool:
845
+ return capability.strip().lower() in {
846
+ "imagegeneration",
847
+ "image_generation",
848
+ "videogeneration",
849
+ "video_generation",
850
+ "speechrecognition",
851
+ "speech_recognition",
852
+ "speech",
853
+ }
854
+
855
+
658
856
  def _normalize_input_capabilities(value: str | None = None) -> str | None:
659
857
  if value is None:
660
858
  return None
@@ -685,7 +883,53 @@ def _video_input_capabilities(
685
883
  value = input_payload.get(key)
686
884
  if value is not None and str(value).strip():
687
885
  return "image"
688
- return "text"
886
+ return "text"
887
+
888
+
889
+ def _image_generation_body(
890
+ prompt: str,
891
+ model: str,
892
+ *,
893
+ count: int | None = None,
894
+ negative_prompt: str | None = None,
895
+ size: str | None = None,
896
+ parameters: dict[str, Any] | None = None,
897
+ ) -> dict[str, Any]:
898
+ params = dict(parameters or {})
899
+ image_count = _image_count(count, params)
900
+ params.pop("count", None)
901
+ if image_count is not None:
902
+ params["n"] = image_count
903
+ else:
904
+ params.pop("n", None)
905
+ if negative_prompt:
906
+ params.setdefault("negative_prompt", negative_prompt)
907
+ if size:
908
+ params.setdefault("size", size)
909
+ return {
910
+ "model": model,
911
+ "input": {
912
+ "messages": [
913
+ {
914
+ "role": "user",
915
+ "content": [{"text": prompt}],
916
+ }
917
+ ]
918
+ },
919
+ "parameters": params,
920
+ }
921
+
922
+
923
+ def _image_count(count: int | None, parameters: dict[str, Any]) -> int | None:
924
+ values = [count, parameters.get("n"), parameters.get("count")]
925
+ for value in values:
926
+ try:
927
+ parsed = int(value)
928
+ except (TypeError, ValueError):
929
+ continue
930
+ if parsed > 0:
931
+ return max(1, min(4, parsed))
932
+ return None
689
933
 
690
934
 
691
935
  def _fetch_model_detail(
@@ -698,20 +942,43 @@ def _fetch_model_detail(
698
942
  model_code = str(model_code or "").strip()
699
943
  if not model_code:
700
944
  return None
701
- _ = workspace_id
702
- cache_key = (runtime.base_url, runtime.token, model_code)
945
+ resolved_workspace_id = _workspace_id(workspace_id or runtime.workspace_id)
946
+ cache_key = (runtime.base_url, runtime.token, model_code, _routing_cache_fingerprint(runtime))
703
947
  ttl = _int_env("LICOS_MODEL_CATALOG_CACHE_TTL_SECS", DEFAULT_CATALOG_CACHE_TTL_SECS)
704
948
  cached = _DETAIL_CACHE.get(cache_key)
705
949
  if cached and not refresh and time.time() - cached[0] <= ttl:
706
950
  return cached[1]
707
951
 
708
952
  query = {"code": model_code}
953
+ if resolved_workspace_id:
954
+ query["workspaceId"] = resolved_workspace_id
955
+ if runtime.owner_project_id:
956
+ query["projectId"] = runtime.owner_project_id
957
+ if runtime.owner_project_type:
958
+ query["ownerProjectType"] = runtime.owner_project_type
959
+ if runtime.owner_application_type:
960
+ query["ownerApplicationType"] = runtime.owner_application_type
961
+ if runtime.workspace_id:
962
+ query["ownerWorkspaceId"] = runtime.workspace_id
963
+ if runtime.owner_org_id:
964
+ query["tenantId"] = runtime.owner_org_id
965
+ query["ownerOrgId"] = runtime.owner_org_id
966
+ if runtime.user_id:
967
+ query["ownerUserId"] = runtime.user_id
968
+ if runtime.runtime_env:
969
+ query["runtimeEnv"] = runtime.runtime_env
970
+ if runtime.current_user_id:
971
+ query["currentUserId"] = runtime.current_user_id
972
+ if runtime.current_org_id:
973
+ query["currentOrgId"] = runtime.current_org_id
974
+ query["currentTenantId"] = runtime.current_org_id
709
975
  url = f"{runtime.base_url}{MODEL_DETAIL_PATH}?{parse.urlencode(query)}"
710
976
  try:
711
977
  payload = _request_json(
712
978
  "GET",
713
979
  url,
714
980
  token=runtime.token,
981
+ headers=_routing_headers(runtime),
715
982
  timeout=30,
716
983
  )
717
984
  except ApiError as exc:
@@ -821,6 +1088,7 @@ def _resolve_endpoint(
821
1088
  default_endpoint: ModelEndpoint | None = None
822
1089
  for item in catalogs:
823
1090
  for endpoint in _endpoint_candidates(item, capability_key, model_group):
1091
+ endpoint = _with_request_headers(endpoint, runtime)
824
1092
  default_endpoint = default_endpoint or endpoint
825
1093
  if requested_model and _selected_model(requested_model, "") == endpoint.model:
826
1094
  return endpoint
@@ -829,6 +1097,11 @@ def _resolve_endpoint(
829
1097
  raise ApiError(f"capability `{capability_key}` is not available in model catalog", details=catalogs)
830
1098
 
831
1099
 
1100
+ def _with_request_headers(endpoint: ModelEndpoint, runtime: ModelRuntime) -> ModelEndpoint:
1101
+ headers = {**_routing_headers(runtime), **endpoint.required_headers}
1102
+ return replace(endpoint, request_headers=headers)
1103
+
1104
+
832
1105
  def _endpoint_candidates(
833
1106
  item: dict[str, Any],
834
1107
  capability_key: str,
@@ -1143,7 +1416,7 @@ def _post_model_json(
1143
1416
  endpoint.base_url,
1144
1417
  token=runtime.token,
1145
1418
  body=body,
1146
- headers=endpoint.required_headers,
1419
+ headers=_task_submit_headers(endpoint),
1147
1420
  timeout=request_timeout,
1148
1421
  )
1149
1422
  except ApiError as exc:
@@ -1154,7 +1427,7 @@ def _post_model_json(
1154
1427
  endpoint.base_url,
1155
1428
  token=refreshed.token,
1156
1429
  body=body,
1157
- headers=endpoint.required_headers,
1430
+ headers=_task_submit_headers(endpoint),
1158
1431
  timeout=request_timeout,
1159
1432
  )
1160
1433
  raise
@@ -1171,7 +1444,7 @@ def _submit_model_task(
1171
1444
  ) -> ModelResult:
1172
1445
  submit_response = _post_model_json(endpoint, runtime, body, timeout=timeout)
1173
1446
  response = submit_response
1174
- if endpoint.async_task and wait:
1447
+ if _is_async_model_task(endpoint) and wait:
1175
1448
  response = _await_async_model_result(
1176
1449
  endpoint,
1177
1450
  runtime,
@@ -1186,7 +1459,7 @@ def _submit_model_task(
1186
1459
  response=response,
1187
1460
  urls=_collect_urls(response),
1188
1461
  texts=_collect_texts(response),
1189
- submit_response=submit_response if endpoint.async_task else None,
1462
+ submit_response=submit_response if _is_async_model_task(endpoint) else None,
1190
1463
  )
1191
1464
 
1192
1465
 
@@ -1204,7 +1477,7 @@ def _stream_model_json(
1204
1477
  endpoint.base_url,
1205
1478
  token=active_runtime.token,
1206
1479
  body=body,
1207
- headers=endpoint.required_headers,
1480
+ headers=_request_headers(endpoint),
1208
1481
  )
1209
1482
  try:
1210
1483
  with request.urlopen(req, timeout=timeout or _request_timeout()) as response:
@@ -1309,7 +1582,7 @@ def _await_async_model_result(
1309
1582
  "GET",
1310
1583
  query_url,
1311
1584
  token=runtime.token,
1312
- headers=endpoint.required_headers,
1585
+ headers=_request_headers(endpoint),
1313
1586
  timeout=timeout or _request_timeout(),
1314
1587
  )
1315
1588
  except ApiError as exc:
@@ -1319,7 +1592,7 @@ def _await_async_model_result(
1319
1592
  "GET",
1320
1593
  query_url,
1321
1594
  token=refreshed.token,
1322
- headers=endpoint.required_headers,
1595
+ headers=_request_headers(endpoint),
1323
1596
  timeout=timeout or _request_timeout(),
1324
1597
  )
1325
1598
  else:
@@ -1484,19 +1757,7 @@ def _selected_model(model: str | None, default: str) -> str:
1484
1757
  return selected
1485
1758
 
1486
1759
 
1487
- def _image_count(count: int | None, parameters: dict[str, Any]) -> int:
1488
- values = [count, parameters.get("n"), parameters.get("count")]
1489
- for value in values:
1490
- try:
1491
- parsed = int(value)
1492
- except (TypeError, ValueError):
1493
- continue
1494
- if parsed > 0:
1495
- return max(1, min(4, parsed))
1496
- return 1
1497
-
1498
-
1499
- def _non_empty_list(values: Sequence[str | None]) -> list[str]:
1760
+ def _non_empty_list(values: Sequence[str | None]) -> list[str]:
1500
1761
  return [value.strip() for value in values if isinstance(value, str) and value.strip()]
1501
1762
 
1502
1763
 
@@ -59,14 +59,14 @@ def _available_models_payload(category_code: str = "llm", input_capabilities: st
59
59
  "baseUrl": "http://gateway.example/v1/chat/completions",
60
60
  }
61
61
  ],
62
- "image_generation": [
63
- {
64
- "providerCode": "test-provider",
65
- "categoryCode": "image_generation",
66
- "modelCode": "image-model",
67
- "baseUrl": "http://gateway.example/image",
68
- }
69
- ],
62
+ "image_generation": [
63
+ {
64
+ "providerCode": "test-provider",
65
+ "categoryCode": "image_generation",
66
+ "modelCode": "image-model",
67
+ "baseUrl": "http://gateway.example/image",
68
+ }
69
+ ],
70
70
  "video_generation": [
71
71
  {
72
72
  "providerCode": "test-provider",
@@ -75,6 +75,8 @@ def _available_models_payload(category_code: str = "llm", input_capabilities: st
75
75
  "baseUrl": "http://gateway.example/video-image"
76
76
  if input_capabilities == "image"
77
77
  else "http://gateway.example/video-text",
78
+ "async": True,
79
+ "taskQueryUrl": "http://gateway.example/tasks/{taskId}",
78
80
  }
79
81
  ],
80
82
  "speech": [
@@ -83,6 +85,7 @@ def _available_models_payload(category_code: str = "llm", input_capabilities: st
83
85
  "categoryCode": "speech",
84
86
  "modelCode": "asr-model",
85
87
  "baseUrl": "http://gateway.example/asr",
88
+ "taskQueryUrl": "http://gateway.example/tasks/{taskId}",
86
89
  }
87
90
  ],
88
91
  }.get(category_code, [])
@@ -105,10 +108,14 @@ def _model_detail_payload(model_code: str = "chat-text") -> dict[str, Any]:
105
108
  }
106
109
  if model_code == "image-model":
107
110
  return {
108
- "code": 0,
109
- "success": True,
110
- "data": {"code": model_code, "baseUrl": "http://gateway.example/image"},
111
- }
111
+ "code": 0,
112
+ "success": True,
113
+ "data": {
114
+ "code": model_code,
115
+ "baseUrl": "http://gateway.example/image",
116
+ "requiredHeaders": [],
117
+ },
118
+ }
112
119
  if model_code in ("video-text-model", "video-image-model"):
113
120
  suffix = "image" if model_code == "video-image-model" else "text"
114
121
  return {
@@ -116,6 +123,12 @@ def _model_detail_payload(model_code: str = "chat-text") -> dict[str, Any]:
116
123
  "success": True,
117
124
  "data": {"code": model_code, "baseUrl": f"http://gateway.example/video-{suffix}"},
118
125
  }
126
+ if model_code == "asr-model":
127
+ return {
128
+ "code": 0,
129
+ "success": True,
130
+ "data": {"code": model_code, "baseUrl": "http://gateway.example/asr"},
131
+ }
119
132
  return {
120
133
  "code": 0,
121
134
  "success": True,
@@ -152,6 +165,8 @@ class ModelSdkTests(unittest.TestCase):
152
165
  "LICOS_PLATFORM_API_BASE_URL": "http://platform.example/api/v1",
153
166
  "AGENT_USER_ID": "user-1",
154
167
  "AGENT_WORKSPACE_ID": "workspace-1",
168
+ "LICOS_CURRENT_USER_ID": "current-user-1",
169
+ "LICOS_CURRENT_ORG_ID": "current-org-1",
155
170
  "LICOS_AI_AGENT_TOKEN": "ai-agent-token",
156
171
  },
157
172
  clear=True,
@@ -170,6 +185,7 @@ class ModelSdkTests(unittest.TestCase):
170
185
  captured["exchange_body"] = json.loads(req.data.decode("utf-8"))
171
186
  return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
172
187
  if _is_available_models_request(req):
188
+ captured["catalog_url"] = req.full_url
173
189
  captured["catalog_headers"] = dict(req.header_items())
174
190
  return _available_models_response(req)
175
191
  if req.full_url.startswith("http://platform.example/api/v1/admin/workspaces/models/detail?"):
@@ -193,6 +209,40 @@ class ModelSdkTests(unittest.TestCase):
193
209
  self.assertEqual(captured["chat_body"]["model"], "chat-text")
194
210
  self.assertEqual(captured["chat_body"]["max_completion_tokens"], 64000)
195
211
 
212
+ def test_model_requests_fetch_current_user_context_when_env_missing(self) -> None:
213
+ os.environ.pop("LICOS_CURRENT_USER_ID", None)
214
+ os.environ.pop("LICOS_CURRENT_ORG_ID", None)
215
+ captured: dict[str, Any] = {}
216
+
217
+ def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
218
+ if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
219
+ return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
220
+ if req.full_url == "http://platform.example/api/v1/admin/auth/me":
221
+ captured["me_headers"] = dict(req.header_items())
222
+ return _FakeResponse(
223
+ {"code": 0, "success": True, "data": {"id": "current-user-token", "tenantId": "current-org-token"}}
224
+ )
225
+ if _is_available_models_request(req):
226
+ captured["catalog_url"] = req.full_url
227
+ captured["catalog_headers"] = dict(req.header_items())
228
+ return _available_models_response(req)
229
+ if req.full_url.startswith("http://platform.example/api/v1/admin/workspaces/models/detail?"):
230
+ return _FakeResponse(_model_detail_payload("chat-text"))
231
+ raise AssertionError(req.full_url)
232
+
233
+ with mock.patch.object(model.request, "urlopen", fake_urlopen):
234
+ endpoint = model.resolve_llm_endpoint()
235
+
236
+ query = parse.parse_qs(parse.urlparse(captured["catalog_url"]).query)
237
+ self.assertEqual(endpoint.request_headers["X-Licos-Current-User-Id"], "current-user-token")
238
+ self.assertEqual(endpoint.request_headers["X-Licos-Current-Org-Id"], "current-org-token")
239
+ self.assertEqual(captured["me_headers"]["Authorization"], "Bearer user-token")
240
+ catalog_headers = {key.lower(): value for key, value in captured["catalog_headers"].items()}
241
+ self.assertEqual(catalog_headers["x-licos-current-user-id"], "current-user-token")
242
+ self.assertEqual(catalog_headers["x-licos-current-org-id"], "current-org-token")
243
+ self.assertEqual(query["currentUserId"], ["current-user-token"])
244
+ self.assertEqual(query["currentOrgId"], ["current-org-token"])
245
+
196
246
  def test_llm_explicit_model_overrides_catalog_default(self) -> None:
197
247
  captured: dict[str, Any] = {}
198
248
 
@@ -214,6 +264,38 @@ class ModelSdkTests(unittest.TestCase):
214
264
  self.assertEqual(result.text, "hello")
215
265
  self.assertEqual(captured["chat_body"]["model"], "custom-chat-model")
216
266
 
267
+ def test_model_catalog_request_includes_owner_project_types_when_configured(self) -> None:
268
+ os.environ["LICOS_PROJECT_ID"] = "project-1"
269
+ os.environ["LICOS_PROJECT_TYPE"] = "AGENT"
270
+ os.environ["LICOS_OWNER_APPLICATION_TYPE"] = "AICoding"
271
+ os.environ["LICOS_OWNER_ORG_ID"] = "owner-org-1"
272
+ os.environ["LICOS_RUNTIME_ENV"] = "dev"
273
+ captured: dict[str, Any] = {}
274
+
275
+ def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
276
+ if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
277
+ return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
278
+ if _is_available_models_request(req):
279
+ captured["catalog_url"] = req.full_url
280
+ captured["catalog_headers"] = dict(req.header_items())
281
+ return _available_models_response(req)
282
+ if req.full_url.startswith("http://platform.example/api/v1/admin/workspaces/models/detail?"):
283
+ captured["detail_url"] = req.full_url
284
+ return _FakeResponse(_model_detail_payload("chat-text"))
285
+ raise AssertionError(req.full_url)
286
+
287
+ with mock.patch.object(model.request, "urlopen", fake_urlopen):
288
+ endpoint = model.resolve_llm_endpoint()
289
+
290
+ catalog_query = parse.parse_qs(parse.urlparse(captured["catalog_url"]).query)
291
+ detail_query = parse.parse_qs(parse.urlparse(captured["detail_url"]).query)
292
+ self.assertEqual(endpoint.request_headers["X-Licos-Owner-Project-Type"], "AGENT")
293
+ self.assertEqual(endpoint.request_headers["X-Licos-Owner-Application-Type"], "AICoding")
294
+ self.assertEqual(catalog_query["ownerProjectType"], ["AGENT"])
295
+ self.assertEqual(catalog_query["ownerApplicationType"], ["AICoding"])
296
+ self.assertEqual(detail_query["ownerProjectType"], ["AGENT"])
297
+ self.assertEqual(detail_query["ownerApplicationType"], ["AICoding"])
298
+
217
299
  def test_llm_invoke_refreshes_user_token_once_after_unauthorized(self) -> None:
218
300
  tokens = iter(["old-token", "new-token"])
219
301
  catalog_tokens: list[str] = []
@@ -329,7 +411,7 @@ class ModelSdkTests(unittest.TestCase):
329
411
  self.assertEqual(kwargs["timeout"], 30)
330
412
  self.assertTrue(kwargs["streaming"])
331
413
 
332
- def test_image_generation_defaults_to_one_image(self) -> None:
414
+ def test_image_generation_uses_sync_multimodal_body(self) -> None:
333
415
  captured: dict[str, Any] = {}
334
416
 
335
417
  def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
@@ -340,6 +422,7 @@ class ModelSdkTests(unittest.TestCase):
340
422
  if req.full_url.startswith("http://platform.example/api/v1/admin/workspaces/models/detail?"):
341
423
  return _FakeResponse(_model_detail_payload("image-model"))
342
424
  if req.full_url == "http://gateway.example/image":
425
+ captured["headers"] = dict(req.header_items())
343
426
  captured["body"] = json.loads(req.data.decode("utf-8"))
344
427
  return _FakeResponse({"output": {"url": "https://cdn.example/image.png"}})
345
428
  raise AssertionError(req.full_url)
@@ -347,9 +430,13 @@ class ModelSdkTests(unittest.TestCase):
347
430
  with mock.patch.object(model.request, "urlopen", fake_urlopen):
348
431
  result = model.ImageGenerationClient().generate("blue sky")
349
432
 
350
- self.assertEqual(captured["body"]["model"], "image-model")
351
- self.assertEqual(captured["body"]["parameters"]["n"], 1)
352
- self.assertEqual(result.urls, ["https://cdn.example/image.png"])
433
+ self.assertEqual(captured["body"]["model"], "image-model")
434
+ self.assertEqual(captured["body"]["input"]["messages"][0]["role"], "user")
435
+ self.assertEqual(captured["body"]["input"]["messages"][0]["content"][0]["text"], "blue sky")
436
+ self.assertNotIn("n", captured["body"]["parameters"])
437
+ headers = {key.lower(): value for key, value in captured["headers"].items()}
438
+ self.assertNotIn("x-dashscope-async", headers)
439
+ self.assertEqual(result.urls, ["https://cdn.example/image.png"])
353
440
 
354
441
  def test_video_generation_uses_text_input_capability_without_image(self) -> None:
355
442
  captured: dict[str, Any] = {}
@@ -363,6 +450,7 @@ class ModelSdkTests(unittest.TestCase):
363
450
  if req.full_url.startswith("http://platform.example/api/v1/admin/workspaces/models/detail?"):
364
451
  return _FakeResponse(_model_detail_payload("video-text-model"))
365
452
  if req.full_url == "http://gateway.example/video-text":
453
+ captured["headers"] = dict(req.header_items())
366
454
  captured["body"] = json.loads(req.data.decode("utf-8"))
367
455
  return _FakeResponse({"output": {"video_url": "https://cdn.example/text.mp4"}})
368
456
  raise AssertionError(req.full_url)
@@ -372,8 +460,38 @@ class ModelSdkTests(unittest.TestCase):
372
460
 
373
461
  self.assertEqual(captured["catalog_query"]["inputCapabilities"], ["text"])
374
462
  self.assertEqual(captured["body"]["model"], "video-text-model")
463
+ headers = {key.lower(): value for key, value in captured["headers"].items()}
464
+ self.assertEqual(headers["x-dashscope-async"], "enable")
375
465
  self.assertEqual(result.urls, ["https://cdn.example/text.mp4"])
376
466
 
467
+ def test_speech_recognition_submits_with_async_header(self) -> None:
468
+ captured: dict[str, Any] = {}
469
+
470
+ def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
471
+ if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
472
+ return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
473
+ if _is_available_models_request(req):
474
+ return _available_models_response(req)
475
+ if req.full_url.startswith("http://platform.example/api/v1/admin/workspaces/models/detail?"):
476
+ return _FakeResponse(_model_detail_payload("asr-model"))
477
+ if req.full_url == "http://gateway.example/asr":
478
+ captured["headers"] = dict(req.header_items())
479
+ captured["body"] = json.loads(req.data.decode("utf-8"))
480
+ return _FakeResponse({"output": {"task_id": "asr-task-1", "task_status": "PENDING"}})
481
+ raise AssertionError(req.full_url)
482
+
483
+ with mock.patch.object(model.request, "urlopen", fake_urlopen):
484
+ result = model.SpeechRecognitionClient().recognize(
485
+ audio_url="https://cdn.example/a.wav",
486
+ wait=False,
487
+ )
488
+
489
+ headers = {key.lower(): value for key, value in captured["headers"].items()}
490
+ self.assertEqual(headers["x-dashscope-async"], "enable")
491
+ self.assertEqual(captured["body"]["model"], "asr-model")
492
+ self.assertEqual(captured["body"]["input"]["file_urls"], ["https://cdn.example/a.wav"])
493
+ self.assertEqual(result.submit_response["output"]["task_id"], "asr-task-1")
494
+
377
495
  def test_video_generation_uses_image_input_capability_with_image(self) -> None:
378
496
  captured: dict[str, Any] = {}
379
497
 
File without changes