licos-dev-sdk 0.4.5__tar.gz → 0.4.7__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 (53) hide show
  1. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/PKG-INFO +1 -1
  2. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/pyproject.toml +1 -1
  3. licos_dev_sdk-0.4.7/src/licos_dev_sdk/_usage_identity.py +59 -0
  4. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/model.py +63 -7
  5. licos_dev_sdk-0.4.7/tests/test_langchain_protocol.py +154 -0
  6. licos_dev_sdk-0.4.7/tests/test_usage_identity.py +176 -0
  7. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/.gitignore +0 -0
  8. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/DOCX.md +0 -0
  9. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/__init__.py +0 -0
  10. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/_utils.py +0 -0
  11. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/archive.py +0 -0
  12. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/chart.py +0 -0
  13. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/data.py +0 -0
  14. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/diagram.py +0 -0
  15. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document.py +0 -0
  16. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_common.py +0 -0
  17. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx.py +0 -0
  18. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_edit.py +0 -0
  19. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_expectations.py +0 -0
  20. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_inspect.py +0 -0
  21. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  22. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_package.py +0 -0
  23. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_preservation.py +0 -0
  24. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_structure.py +0 -0
  25. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_table.py +0 -0
  26. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_table_actions.py +0 -0
  27. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_read.py +0 -0
  28. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_table_export.py +0 -0
  29. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/image.py +0 -0
  30. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/observability.py +0 -0
  31. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  32. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
  33. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation.py +0 -0
  34. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation_canvas.py +0 -0
  35. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation_inspect.py +0 -0
  36. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet.py +0 -0
  37. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
  38. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
  39. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  40. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/web.py +0 -0
  41. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_document_read.py +0 -0
  42. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_document_spreadsheet.py +0 -0
  43. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_document_table_export.py +0 -0
  44. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_docx_inspect.py +0 -0
  45. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_docx_structure.py +0 -0
  46. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_docx_table_edit.py +0 -0
  47. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_html_docx_features.py +0 -0
  48. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_model.py +0 -0
  49. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_observability.py +0 -0
  50. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_output_paths.py +0 -0
  51. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_ppt_master_runtime.py +0 -0
  52. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_presentation.py +0 -0
  53. {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_spreadsheet_html_edit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.4.5
3
+ Version: 0.4.7
4
4
  Summary: LICOS Dev SDK - file generation and model capability clients
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: beautifulsoup4>=4.12
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-sdk"
7
- version = "0.4.5"
7
+ version = "0.4.7"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -0,0 +1,59 @@
1
+ """Renew existing model billing signatures without changing routing headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from datetime import datetime
8
+ from typing import Any, Callable
9
+
10
+ from licos_platform_sdk._runtime import ApiError
11
+
12
+ IDENTITY_HEADERS = (
13
+ "X-AIOS-Usage-Context",
14
+ "X-AIOS-Usage-Context-Signature",
15
+ "X-AIOS-Provider-Credential-Id",
16
+ "X-AIOS-Provider-Workspace-Id",
17
+ )
18
+ REFRESH_LEAD_SECS = 30
19
+
20
+
21
+ def has_signature(headers: dict[str, str]) -> bool:
22
+ return any(name.lower() in {value.lower() for value in IDENTITY_HEADERS[:2]} for name in headers)
23
+
24
+
25
+ class UsageIdentityLease:
26
+ def __init__(self, detail: dict[str, Any] | None, parse_headers: Callable[[Any], dict[str, str]]) -> None:
27
+ self._parse_headers = parse_headers
28
+ self._headers, self._expires_at = self._parse(detail)
29
+ self._lock = threading.Lock()
30
+
31
+ def _parse(self, detail: dict[str, Any] | None) -> tuple[dict[str, str], float]:
32
+ if detail is None:
33
+ raise ApiError("model detail is missing usage identity")
34
+ raw = self._parse_headers(detail.get("requiredHeaders") or detail.get("required_headers"))
35
+ normalized = {name.lower(): value for name, value in raw.items()}
36
+ headers = {}
37
+ for name in IDENTITY_HEADERS:
38
+ value = normalized.get(name.lower())
39
+ if not value:
40
+ raise ApiError(f"model detail is missing {name}")
41
+ headers[name] = value
42
+ expiry = detail.get("usageContextExpiresAt")
43
+ try:
44
+ parsed = datetime.fromisoformat(str(expiry).replace("Z", "+00:00"))
45
+ if parsed.tzinfo is None:
46
+ raise ValueError("expiry requires timezone")
47
+ expires_at = parsed.timestamp()
48
+ except ValueError as exc:
49
+ raise ApiError("model detail has invalid usageContextExpiresAt") from exc
50
+ return headers, expires_at
51
+
52
+ def current_headers(self, fetch_detail: Callable[[], dict[str, Any] | None]) -> dict[str, str]:
53
+ with self._lock:
54
+ if self._expires_at <= time.time() + REFRESH_LEAD_SECS:
55
+ headers, expires_at = self._parse(fetch_detail())
56
+ if expires_at <= time.time() + REFRESH_LEAD_SECS:
57
+ raise ApiError("renewed model usage identity expires before the request can start")
58
+ self._headers, self._expires_at = headers, expires_at
59
+ return dict(self._headers)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import atexit
4
+ import asyncio
4
5
  import base64
5
6
  import json
6
7
  import logging
@@ -25,6 +26,7 @@ from licos_platform_sdk._runtime import (
25
26
  resolve_user_token,
26
27
  should_refresh_user_token,
27
28
  )
29
+ from ._usage_identity import UsageIdentityLease, has_signature
28
30
 
29
31
 
30
32
  AVAILABLE_MODELS_PATH_TEMPLATE = "/api/v1/admin/workspaces/{workspace_id}/models/available"
@@ -92,6 +94,7 @@ class ModelEndpoint:
92
94
  context_length: int | None = None
93
95
  max_input_length: int | None = None
94
96
  max_output_length: int | None = None
97
+ usage_identity: UsageIdentityLease | None = field(default=None, repr=False, compare=False)
95
98
 
96
99
  @property
97
100
  def async_task(self) -> bool:
@@ -482,6 +485,7 @@ def create_chat_openai(
482
485
  max_completion_tokens: int | None = None,
483
486
  timeout: int | None = None,
484
487
  streaming: bool = True,
488
+ use_responses_api: bool | None = None,
485
489
  default_headers: dict[str, str] | None = None,
486
490
  **kwargs: Any,
487
491
  ) -> Any:
@@ -490,6 +494,8 @@ def create_chat_openai(
490
494
  `model=None`, an empty string, or `model="auto"` uses the catalog default. The
491
495
  returned ChatOpenAI receives the current user token as its OpenAI-compatible API
492
496
  key, so deployed projects do not need LLM_MODEL/OPENAI_MODEL env injection.
497
+ A catalog `response_url` selects Responses by default. Set
498
+ `use_responses_api=False` to use Chat Completions explicitly.
493
499
  """
494
500
  try:
495
501
  from langchain_core.callbacks import BaseCallbackHandler
@@ -500,6 +506,11 @@ def create_chat_openai(
500
506
 
501
507
  runtime = _model_runtime(base_url=base_url, user_token=user_token, user_id=user_id, workspace_id=workspace_id)
502
508
  endpoint = _resolve_chat_endpoint(runtime, model_group=model_group, requested_model=model)
509
+ if use_responses_api is None:
510
+ use_responses_api = bool(endpoint.response_url)
511
+ if use_responses_api and not endpoint.response_url:
512
+ raise ConfigurationError(f"Model {endpoint.model!r} has no response_url configured")
513
+ endpoint_url = endpoint.response_url if use_responses_api else endpoint.base_url
503
514
  headers = _without_authorization(
504
515
  _merge_headers(
505
516
  endpoint.request_headers or endpoint.required_headers,
@@ -540,7 +551,8 @@ def create_chat_openai(
540
551
  )
541
552
  chat_kwargs: dict[str, Any] = {
542
553
  "model": endpoint.model,
543
- "base_url": _chat_completions_sdk_base_url(endpoint.base_url),
554
+ "base_url": _openai_sdk_base_url(endpoint_url, use_responses_api=use_responses_api),
555
+ "use_responses_api": use_responses_api,
544
556
  "api_key": api_key or runtime.token,
545
557
  "streaming": streaming,
546
558
  **_not_none(
@@ -566,8 +578,11 @@ def _LangChainUsageHandler(
566
578
  endpoint: ModelEndpoint,
567
579
  ) -> Any:
568
580
  class UsageHandler(base_handler):
581
+ run_inline = True
582
+
569
583
  def __init__(self) -> None:
570
584
  super().__init__()
585
+ self._routing_headers = _routing_headers(runtime)
571
586
  self._request_id: ContextVar[str | None] = ContextVar(
572
587
  "licos_langchain_model_request_id",
573
588
  default=None,
@@ -586,12 +601,37 @@ def _LangChainUsageHandler(
586
601
  def on_chat_model_start(self, *_args: Any, **_kwargs: Any) -> None:
587
602
  self._current_request_id()
588
603
 
604
+ def _matches_model_request(self, request_value: Any) -> bool:
605
+ if request_value.method != "POST" or request_value.headers.get("content-type", "").split(";")[0] != "application/json":
606
+ return False
607
+ return json.loads(request_value.content).get("model") == endpoint.model and all(
608
+ request_value.headers.get(name) == value for name, value in self._routing_headers.items()
609
+ )
610
+
589
611
  def on_http_request(self, request_value: Any) -> None:
612
+ if not self._matches_model_request(request_value):
613
+ return
590
614
  request_id = self._current_request_id()
591
615
  request_value.headers[MODEL_REQUEST_ID_HEADER] = request_id
616
+ if request_value.extensions.get("licos_usage_identity_managed"):
617
+ return
618
+ self._apply_usage_identity(request_value, _current_usage_identity(endpoint, runtime))
619
+
620
+ def _apply_usage_identity(self, request_value: Any, headers: dict[str, str]) -> None:
621
+ for name, value in headers.items():
622
+ for existing in list(request_value.headers):
623
+ if existing.lower() == name.lower():
624
+ del request_value.headers[existing]
625
+ request_value.headers[name] = value
592
626
 
593
627
  async def on_async_http_request(self, request_value: Any) -> None:
594
- self.on_http_request(request_value)
628
+ if not self._matches_model_request(request_value):
629
+ return
630
+ request_value.headers[MODEL_REQUEST_ID_HEADER] = self._current_request_id()
631
+ if request_value.extensions.get("licos_usage_identity_managed"):
632
+ return
633
+ headers = await asyncio.to_thread(_current_usage_identity, endpoint, runtime)
634
+ self._apply_usage_identity(request_value, headers)
595
635
 
596
636
  def on_llm_end(self, response: Any, **_kwargs: Any) -> None:
597
637
  usage = None
@@ -622,9 +662,10 @@ def _LangChainUsageHandler(
622
662
  return UsageHandler()
623
663
 
624
664
 
625
- def _chat_completions_sdk_base_url(url: str) -> str:
665
+ def _openai_sdk_base_url(url: str, *, use_responses_api: bool) -> str:
626
666
  trimmed = str(url or "").strip().rstrip("/")
627
- return trimmed.removesuffix("/chat/completions").rstrip("/")
667
+ suffix = "/responses" if use_responses_api else "/chat/completions"
668
+ return trimmed.removesuffix(suffix).rstrip("/")
628
669
 
629
670
 
630
671
  def resolve_vision_endpoint(
@@ -1630,7 +1671,22 @@ def _enterprise_org_id(value: str | None) -> str | None:
1630
1671
 
1631
1672
  def _request_headers(endpoint: ModelEndpoint, runtime: ModelRuntime) -> dict[str, str]:
1632
1673
  headers = endpoint.request_headers or endpoint.required_headers
1633
- return _without_authorization(_merge_headers(headers, _routing_headers(runtime)))
1674
+ return _without_authorization(_merge_headers(headers, _routing_headers(runtime), _current_usage_identity(endpoint, runtime)))
1675
+
1676
+
1677
+ def _current_usage_identity(endpoint: ModelEndpoint, runtime: ModelRuntime) -> dict[str, str]:
1678
+ if endpoint.usage_identity is None:
1679
+ return {}
1680
+ return endpoint.usage_identity.current_headers(
1681
+ lambda: _fetch_model_detail(runtime, endpoint.model, refresh=True)
1682
+ )
1683
+
1684
+
1685
+ def _with_usage_identity(endpoint: ModelEndpoint, detail: dict[str, Any] | None) -> ModelEndpoint:
1686
+ detail_headers = _parse_required_headers((detail or {}).get("requiredHeaders") or (detail or {}).get("required_headers"))
1687
+ if not has_signature(_merge_headers(endpoint.required_headers, endpoint.request_headers, detail_headers)):
1688
+ return endpoint
1689
+ return replace(endpoint, usage_identity=UsageIdentityLease(detail, _parse_required_headers))
1634
1690
 
1635
1691
 
1636
1692
  def _merge_headers(*groups: dict[str, str] | None) -> dict[str, str]:
@@ -2120,7 +2176,7 @@ def _resolve_chat_endpoint(
2120
2176
  selected_model = _selected_model(requested_model, endpoint.model)
2121
2177
  endpoint = replace(endpoint, model=selected_model)
2122
2178
  detail = _fetch_model_detail(runtime, selected_model)
2123
- return _apply_model_detail(endpoint, detail)
2179
+ return _with_usage_identity(_apply_model_detail(endpoint, detail), detail)
2124
2180
 
2125
2181
 
2126
2182
  def _resolve_endpoint_with_detail(
@@ -2139,7 +2195,7 @@ def _resolve_endpoint_with_detail(
2139
2195
  input_capabilities=input_capabilities,
2140
2196
  )
2141
2197
  detail = _fetch_model_detail(runtime, endpoint.model)
2142
- return _apply_model_detail(endpoint, detail)
2198
+ return _with_usage_identity(_apply_model_detail(endpoint, detail), detail)
2143
2199
 
2144
2200
 
2145
2201
  def _resolve_endpoint(
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ import httpx
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"))
14
+
15
+ from licos_dev_sdk import model
16
+ from licos_platform_sdk._runtime import ConfigurationError
17
+
18
+
19
+ CHAT_URL = "https://chat.example/compatible/v1/chat/completions"
20
+ RESPONSES_URL = "https://responses.example/api/v1/responses"
21
+
22
+
23
+ class LangChainProtocolTests(unittest.TestCase):
24
+ def setUp(self):
25
+ runtime = model.ModelRuntime(base_url="https://platform.example", token="user-token")
26
+ self.runtime_patch = patch.object(model, "_model_runtime", return_value=runtime)
27
+ self.usage_patch = patch.object(model, "_enqueue_model_usage")
28
+ self.runtime_patch.start()
29
+ self.usage = self.usage_patch.start()
30
+ self.addCleanup(self.runtime_patch.stop)
31
+ self.addCleanup(self.usage_patch.stop)
32
+
33
+ def endpoint(self, response_url):
34
+ return model.ModelEndpoint(
35
+ provider="test", capability="chat", model="test-model", base_url=CHAT_URL,
36
+ response_url=response_url, request_headers={"X-Provider-Route": "route-1"},
37
+ )
38
+
39
+ def response(self, request):
40
+ if str(request.url) == RESPONSES_URL:
41
+ body = {
42
+ "id": "resp_1", "object": "response", "created_at": 1,
43
+ "model": "test-model", "status": "completed", "error": None,
44
+ "output": [{"id": "msg_1", "type": "message", "role": "assistant", "status": "completed",
45
+ "content": [{"type": "output_text", "text": "answer", "annotations": []}]}],
46
+ "usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5},
47
+ }
48
+ else:
49
+ self.assertEqual(str(request.url), CHAT_URL)
50
+ body = {
51
+ "id": "chat_1", "object": "chat.completion", "created": 1, "model": "test-model",
52
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "answer"}, "finish_reason": "stop"}],
53
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
54
+ }
55
+ return httpx.Response(200, json=body)
56
+
57
+ def test_catalog_selects_actual_protocol_and_address(self):
58
+ for response_url, override, expected in [
59
+ (RESPONSES_URL, None, RESPONSES_URL),
60
+ (None, None, CHAT_URL),
61
+ (RESPONSES_URL, False, CHAT_URL),
62
+ (RESPONSES_URL, True, RESPONSES_URL),
63
+ ]:
64
+ for asynchronous in (False, True):
65
+ with self.subTest(response_url=response_url, override=override, asynchronous=asynchronous):
66
+ requests = []
67
+
68
+ def handle(request):
69
+ requests.append(request)
70
+ return self.response(request)
71
+
72
+ transport = httpx.MockTransport(handle)
73
+ sync_client = httpx.Client(transport=transport)
74
+ async_client = httpx.AsyncClient(transport=transport)
75
+ try:
76
+ with patch.object(model, "_resolve_chat_endpoint", return_value=self.endpoint(response_url)):
77
+ options = {} if override is None else {"use_responses_api": override}
78
+ chat = model.create_chat_openai(streaming=False, http_client=sync_client,
79
+ http_async_client=async_client, **options)
80
+ result = asyncio.run(chat.ainvoke("question")) if asynchronous else chat.invoke("question")
81
+ self.assertEqual(len(requests), 1)
82
+ self.assertEqual(str(requests[0].url), expected)
83
+ payload = json.loads(requests[0].content)
84
+ self.assertIn("input" if expected == RESPONSES_URL else "messages", payload)
85
+ self.assertEqual(requests[0].headers["Authorization"], "Bearer user-token")
86
+ self.assertEqual(requests[0].headers["X-Provider-Route"], "route-1")
87
+ self.assertTrue(requests[0].headers[model.MODEL_REQUEST_ID_HEADER])
88
+ self.assertEqual(result.text, "answer")
89
+ self.assertEqual(result.usage_metadata["total_tokens"], 5)
90
+ self.assertEqual(chat.use_responses_api, expected == RESPONSES_URL)
91
+ finally:
92
+ sync_client.close()
93
+ asyncio.run(async_client.aclose())
94
+
95
+ def test_explicit_responses_requires_catalog_address(self):
96
+ with patch.object(model, "_resolve_chat_endpoint", return_value=self.endpoint(None)):
97
+ with self.assertRaisesRegex(ConfigurationError, "response_url"):
98
+ model.create_chat_openai(use_responses_api=True)
99
+
100
+ def test_streaming_uses_selected_endpoint_and_keeps_usage(self):
101
+ for response_url in (RESPONSES_URL, None):
102
+ for asynchronous in (False, True):
103
+ with self.subTest(response_url=response_url, asynchronous=asynchronous):
104
+ requests = []
105
+
106
+ def handle(request):
107
+ requests.append(request)
108
+ body = self.response(request).json()
109
+ if response_url:
110
+ events = [
111
+ {"type": "response.created", "response": {**body, "status": "in_progress", "output": []}},
112
+ {"type": "response.output_text.delta", "output_index": 0, "content_index": 0,
113
+ "item_id": "msg_1", "delta": "answer"},
114
+ {"type": "response.output_text.done", "output_index": 0, "content_index": 0,
115
+ "item_id": "msg_1", "text": "answer"},
116
+ {"type": "response.completed", "response": body},
117
+ ]
118
+ text = "".join(f"event: {event['type']}\ndata: {json.dumps({**event, 'sequence_number': i})}\n\n"
119
+ for i, event in enumerate(events))
120
+ else:
121
+ base = {"id": "chat_1", "object": "chat.completion.chunk", "created": 1, "model": "test-model"}
122
+ events = [
123
+ {**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "answer"}, "finish_reason": None}]},
124
+ {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
125
+ {**base, "choices": [], "usage": body["usage"]},
126
+ ]
127
+ text = "".join(f"data: {json.dumps(event)}\n\n" for event in events) + "data: [DONE]\n\n"
128
+ return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=text)
129
+
130
+ transport = httpx.MockTransport(handle)
131
+ sync_client = httpx.Client(transport=transport)
132
+ async_client = httpx.AsyncClient(transport=transport)
133
+ try:
134
+ with patch.object(model, "_resolve_chat_endpoint", return_value=self.endpoint(response_url)):
135
+ chat = model.create_chat_openai(http_client=sync_client, http_async_client=async_client)
136
+
137
+ async def collect():
138
+ return [chunk async for chunk in chat.astream("question")]
139
+
140
+ chunks = asyncio.run(collect()) if asynchronous else list(chat.stream("question"))
141
+ combined = chunks[0]
142
+ for chunk in chunks[1:]:
143
+ combined += chunk
144
+ self.assertEqual(str(requests[0].url), response_url or CHAT_URL)
145
+ self.assertTrue(json.loads(requests[0].content)["stream"])
146
+ self.assertEqual(combined.text, "answer")
147
+ self.assertEqual(combined.usage_metadata["total_tokens"], 5)
148
+ finally:
149
+ sync_client.close()
150
+ asyncio.run(async_client.aclose())
151
+
152
+
153
+ if __name__ == "__main__":
154
+ unittest.main()
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ import unittest
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"))
13
+
14
+ import httpx
15
+
16
+ from licos_dev_sdk import model
17
+ from licos_dev_sdk._usage_identity import IDENTITY_HEADERS, UsageIdentityLease
18
+ from licos_platform_sdk._runtime import ApiError
19
+
20
+
21
+ def detail(context: str, expires_at: float) -> dict:
22
+ return {
23
+ "usageContextExpiresAt": datetime.fromtimestamp(expires_at, timezone.utc).isoformat(),
24
+ "requiredHeaders": {
25
+ IDENTITY_HEADERS[0].lower(): context,
26
+ IDENTITY_HEADERS[1]: f"{context}-signature",
27
+ IDENTITY_HEADERS[2]: f"{context}-credential",
28
+ IDENTITY_HEADERS[3]: f"{context}-workspace",
29
+ "X-Provider-Unrelated": "must-not-merge",
30
+ "X-Licos-Owner-User-Id": "must-not-replace",
31
+ },
32
+ }
33
+
34
+
35
+ def endpoint(expires_at: float) -> model.ModelEndpoint:
36
+ return model._with_usage_identity(
37
+ model.ModelEndpoint(
38
+ provider="bailian", capability="videoGeneration", model="qwen-test",
39
+ base_url="http://gateway.example/model",
40
+ required_headers={IDENTITY_HEADERS[0].lower(): "catalog-stale", "X-Provider-Route": "route"},
41
+ endpoint={"async": True, "taskQueryUrl": "http://gateway.example/tasks/{taskId}"},
42
+ ),
43
+ detail("initial", expires_at),
44
+ )
45
+
46
+
47
+ class UsageIdentityTests(unittest.TestCase):
48
+ def setUp(self) -> None:
49
+ self.runtime = model.ModelRuntime(
50
+ base_url="http://platform.example", token="user-token", workspace_id="workspace-1",
51
+ user_id="owner-1", current_user_id="caller-1", owner_project_id="project-1",
52
+ )
53
+
54
+ def test_valid_lease_reuses_signed_snapshot(self) -> None:
55
+ with mock.patch("time.time", return_value=100):
56
+ lease = UsageIdentityLease(detail("initial", 200), model._parse_required_headers)
57
+ fetch = mock.Mock()
58
+ snapshot = lease.current_headers(fetch)
59
+ snapshot[IDENTITY_HEADERS[0]] = "mutated"
60
+ self.assertEqual(lease.current_headers(fetch)[IDENTITY_HEADERS[0]], "initial")
61
+ fetch.assert_not_called()
62
+
63
+ def test_concurrent_requests_share_one_near_expiry_refresh(self) -> None:
64
+ with mock.patch("time.time", return_value=100):
65
+ lease = UsageIdentityLease(detail("initial", 120), model._parse_required_headers)
66
+ fetch = mock.Mock(return_value=detail("renewed", 200))
67
+ with ThreadPoolExecutor(max_workers=8) as executor:
68
+ snapshots = list(executor.map(lambda _: lease.current_headers(fetch), range(16)))
69
+ fetch.assert_called_once()
70
+ self.assertTrue(all(value[IDENTITY_HEADERS[0]] == "renewed" for value in snapshots))
71
+
72
+ def test_invalid_signed_detail_is_rejected(self) -> None:
73
+ for missing in (*IDENTITY_HEADERS[1:], "usageContextExpiresAt"):
74
+ invalid = detail("initial", 200)
75
+ if missing == "usageContextExpiresAt":
76
+ del invalid[missing]
77
+ else:
78
+ del invalid["requiredHeaders"][missing]
79
+ with self.subTest(missing=missing), self.assertRaises(ApiError):
80
+ UsageIdentityLease(invalid, model._parse_required_headers)
81
+
82
+ def test_expired_renewal_never_sends_model_request(self) -> None:
83
+ with mock.patch("time.time", return_value=100):
84
+ selected = endpoint(90)
85
+ with mock.patch.object(model, "_fetch_model_detail", return_value=detail("still-expired", 110)), \
86
+ mock.patch.object(model, "_request_json") as request:
87
+ with self.assertRaisesRegex(ApiError, "expires before"):
88
+ model._post_model_json(selected, self.runtime, {"model": "qwen-test"})
89
+ request.assert_not_called()
90
+
91
+ def test_refresh_failure_never_sends_model_request(self) -> None:
92
+ with mock.patch("time.time", return_value=100):
93
+ selected = endpoint(90)
94
+ with mock.patch.object(model, "_fetch_model_detail", side_effect=ApiError("catalog unavailable")), \
95
+ mock.patch.object(model, "_request_json") as request:
96
+ with self.assertRaisesRegex(ApiError, "catalog unavailable"):
97
+ model._post_model_json(selected, self.runtime, {"model": "qwen-test"})
98
+ request.assert_not_called()
99
+
100
+ def test_submit_replaces_only_four_headers_and_retains_owner_and_caller(self) -> None:
101
+ with mock.patch("time.time", return_value=100):
102
+ selected = endpoint(120)
103
+ with mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 200)) as fetch, \
104
+ mock.patch.object(model, "_request_json", return_value={}) as request:
105
+ for _ in range(2):
106
+ model._post_model_json(selected, self.runtime, {"model": "qwen-test"})
107
+ fetch.assert_called_once_with(self.runtime, "qwen-test", refresh=True)
108
+ headers = request.call_args.kwargs["headers"]
109
+ self.assertEqual(headers[IDENTITY_HEADERS[0]], "renewed")
110
+ self.assertNotIn(IDENTITY_HEADERS[0].lower(), headers)
111
+ self.assertEqual(headers["X-Licos-Owner-User-Id"], "owner-1")
112
+ self.assertEqual(headers["X-Licos-Current-User-Id"], "caller-1")
113
+ self.assertEqual(headers["X-Provider-Route"], "route")
114
+ self.assertEqual(headers["X-DashScope-Async"], "enable")
115
+ self.assertNotIn("X-Provider-Unrelated", headers)
116
+
117
+ def test_media_poll_refreshes_after_submit_identity_nears_expiry(self) -> None:
118
+ clock = [100]
119
+ with mock.patch("time.time", side_effect=lambda: clock[0]):
120
+ selected = endpoint(200)
121
+ captured = []
122
+
123
+ def request(method, url, **kwargs):
124
+ captured.append(kwargs["headers"])
125
+ return {"output": {"task_status": "RUNNING" if len(captured) == 1 else "SUCCEEDED"}}
126
+
127
+ with mock.patch.object(model, "_request_json", side_effect=request), \
128
+ mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 500)) as fetch, \
129
+ mock.patch.object(model, "_enqueue_model_usage"), \
130
+ mock.patch("time.sleep", side_effect=lambda _: clock.__setitem__(0, 180)):
131
+ model._await_async_model_result(selected, self.runtime, {"output": {"task_id": "task-1"}})
132
+ fetch.assert_called_once_with(self.runtime, "qwen-test", refresh=True)
133
+ self.assertEqual([headers[IDENTITY_HEADERS[0]] for headers in captured], ["initial", "renewed"])
134
+ self.assertTrue(all(headers["X-Licos-Current-User-Id"] == "caller-1" for headers in captured))
135
+
136
+ def test_langchain_sync_hook_renews_static_default_headers(self) -> None:
137
+ with mock.patch("time.time", return_value=100):
138
+ handler = model._LangChainUsageHandler(object, self.runtime, endpoint(90))
139
+ req = httpx.Request("POST", "http://gateway.example/model", headers={
140
+ IDENTITY_HEADERS[0].lower(): "stale", **model._routing_headers(self.runtime),
141
+ }, json={"model": "qwen-test"})
142
+ with mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 200)) as fetch:
143
+ handler.on_http_request(req)
144
+ handler.on_http_request(req)
145
+ fetch.assert_called_once()
146
+ self.assertEqual(req.headers.get_list(IDENTITY_HEADERS[0]), ["renewed"])
147
+ self.assertEqual(req.headers["X-Licos-Owner-User-Id"], "owner-1")
148
+
149
+ def test_langchain_async_hooks_share_renewal(self) -> None:
150
+ with mock.patch("time.time", return_value=100):
151
+ handler = model._LangChainUsageHandler(object, self.runtime, endpoint(90))
152
+ requests = [httpx.Request("POST", "http://gateway.example/model", json={"model": "qwen-test"},
153
+ headers=model._routing_headers(self.runtime)) for _ in range(8)]
154
+
155
+ async def run():
156
+ await asyncio.gather(*(handler.on_async_http_request(req) for req in requests))
157
+
158
+ with mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 200)) as fetch:
159
+ asyncio.run(run())
160
+ fetch.assert_called_once()
161
+ self.assertTrue(all(req.headers[IDENTITY_HEADERS[0]] == "renewed" for req in requests))
162
+
163
+ def test_shared_http_client_hooks_do_not_replace_other_user_or_model_signatures(self) -> None:
164
+ with mock.patch("time.time", return_value=100):
165
+ handler = model._LangChainUsageHandler(object, self.runtime, endpoint(90))
166
+ for body, headers in [
167
+ ({"model": "another-model"}, model._routing_headers(self.runtime)),
168
+ ({"model": "qwen-test"}, {**model._routing_headers(self.runtime), "X-Licos-Current-User-Id": "another-user"}),
169
+ ]:
170
+ with self.subTest(body=body, headers=headers):
171
+ req = httpx.Request("POST", "http://gateway.example/model", json=body,
172
+ headers={**headers, IDENTITY_HEADERS[0]: "other-context"})
173
+ with mock.patch.object(model, "_fetch_model_detail") as fetch:
174
+ handler.on_http_request(req)
175
+ fetch.assert_not_called()
176
+ self.assertEqual(req.headers[IDENTITY_HEADERS[0]], "other-context")
File without changes
File without changes