licos-dev-sdk 0.4.6__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.6 → licos_dev_sdk-0.4.7}/PKG-INFO +1 -1
  2. {licos_dev_sdk-0.4.6 → 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.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/model.py +50 -4
  5. licos_dev_sdk-0.4.7/tests/test_usage_identity.py +176 -0
  6. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/.gitignore +0 -0
  7. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/DOCX.md +0 -0
  8. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/__init__.py +0 -0
  9. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/_utils.py +0 -0
  10. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/archive.py +0 -0
  11. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/chart.py +0 -0
  12. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/data.py +0 -0
  13. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/diagram.py +0 -0
  14. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document.py +0 -0
  15. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_common.py +0 -0
  16. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx.py +0 -0
  17. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_edit.py +0 -0
  18. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_expectations.py +0 -0
  19. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_inspect.py +0 -0
  20. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  21. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_package.py +0 -0
  22. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_preservation.py +0 -0
  23. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_structure.py +0 -0
  24. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_table.py +0 -0
  25. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_table_actions.py +0 -0
  26. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_read.py +0 -0
  27. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_table_export.py +0 -0
  28. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/image.py +0 -0
  29. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/observability.py +0 -0
  30. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  31. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
  32. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation.py +0 -0
  33. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation_canvas.py +0 -0
  34. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation_inspect.py +0 -0
  35. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet.py +0 -0
  36. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
  37. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
  38. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  39. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/web.py +0 -0
  40. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_document_read.py +0 -0
  41. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_document_spreadsheet.py +0 -0
  42. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_document_table_export.py +0 -0
  43. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_docx_inspect.py +0 -0
  44. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_docx_structure.py +0 -0
  45. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_docx_table_edit.py +0 -0
  46. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_html_docx_features.py +0 -0
  47. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_langchain_protocol.py +0 -0
  48. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_model.py +0 -0
  49. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_observability.py +0 -0
  50. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_output_paths.py +0 -0
  51. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_ppt_master_runtime.py +0 -0
  52. {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.7}/tests/test_presentation.py +0 -0
  53. {licos_dev_sdk-0.4.6 → 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.6
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.6"
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:
@@ -575,8 +578,11 @@ def _LangChainUsageHandler(
575
578
  endpoint: ModelEndpoint,
576
579
  ) -> Any:
577
580
  class UsageHandler(base_handler):
581
+ run_inline = True
582
+
578
583
  def __init__(self) -> None:
579
584
  super().__init__()
585
+ self._routing_headers = _routing_headers(runtime)
580
586
  self._request_id: ContextVar[str | None] = ContextVar(
581
587
  "licos_langchain_model_request_id",
582
588
  default=None,
@@ -595,12 +601,37 @@ def _LangChainUsageHandler(
595
601
  def on_chat_model_start(self, *_args: Any, **_kwargs: Any) -> None:
596
602
  self._current_request_id()
597
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
+
598
611
  def on_http_request(self, request_value: Any) -> None:
612
+ if not self._matches_model_request(request_value):
613
+ return
599
614
  request_id = self._current_request_id()
600
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
601
626
 
602
627
  async def on_async_http_request(self, request_value: Any) -> None:
603
- 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)
604
635
 
605
636
  def on_llm_end(self, response: Any, **_kwargs: Any) -> None:
606
637
  usage = None
@@ -1640,7 +1671,22 @@ def _enterprise_org_id(value: str | None) -> str | None:
1640
1671
 
1641
1672
  def _request_headers(endpoint: ModelEndpoint, runtime: ModelRuntime) -> dict[str, str]:
1642
1673
  headers = endpoint.request_headers or endpoint.required_headers
1643
- 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))
1644
1690
 
1645
1691
 
1646
1692
  def _merge_headers(*groups: dict[str, str] | None) -> dict[str, str]:
@@ -2130,7 +2176,7 @@ def _resolve_chat_endpoint(
2130
2176
  selected_model = _selected_model(requested_model, endpoint.model)
2131
2177
  endpoint = replace(endpoint, model=selected_model)
2132
2178
  detail = _fetch_model_detail(runtime, selected_model)
2133
- return _apply_model_detail(endpoint, detail)
2179
+ return _with_usage_identity(_apply_model_detail(endpoint, detail), detail)
2134
2180
 
2135
2181
 
2136
2182
  def _resolve_endpoint_with_detail(
@@ -2149,7 +2195,7 @@ def _resolve_endpoint_with_detail(
2149
2195
  input_capabilities=input_capabilities,
2150
2196
  )
2151
2197
  detail = _fetch_model_detail(runtime, endpoint.model)
2152
- return _apply_model_detail(endpoint, detail)
2198
+ return _with_usage_identity(_apply_model_detail(endpoint, detail), detail)
2153
2199
 
2154
2200
 
2155
2201
  def _resolve_endpoint(
@@ -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