licos-dev-sdk 0.4.6__tar.gz → 0.4.8__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.
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/PKG-INFO +1 -1
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/pyproject.toml +1 -1
- licos_dev_sdk-0.4.8/src/licos_dev_sdk/_usage_identity.py +60 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/model.py +50 -4
- licos_dev_sdk-0.4.8/tests/test_usage_identity.py +179 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/.gitignore +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/DOCX.md +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/__init__.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_common.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_edit.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_expectations.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_inspect.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_layout.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_package.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_preservation.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_structure.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_table.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_table_actions.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_read.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_table_export.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/pdf_inspect.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/presentation_canvas.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/presentation_inspect.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_document_read.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_document_spreadsheet.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_document_table_export.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_docx_inspect.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_docx_structure.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_docx_table_edit.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_html_docx_features.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_langchain_protocol.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_observability.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_output_paths.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_ppt_master_runtime.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_presentation.py +0 -0
- {licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/tests/test_spreadsheet_html_edit.py +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
"X-Licos-Runtime-Env",
|
|
18
|
+
)
|
|
19
|
+
REFRESH_LEAD_SECS = 30
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def has_signature(headers: dict[str, str]) -> bool:
|
|
23
|
+
return any(name.lower() in {value.lower() for value in IDENTITY_HEADERS[:2]} for name in headers)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UsageIdentityLease:
|
|
27
|
+
def __init__(self, detail: dict[str, Any] | None, parse_headers: Callable[[Any], dict[str, str]]) -> None:
|
|
28
|
+
self._parse_headers = parse_headers
|
|
29
|
+
self._headers, self._expires_at = self._parse(detail)
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def _parse(self, detail: dict[str, Any] | None) -> tuple[dict[str, str], float]:
|
|
33
|
+
if detail is None:
|
|
34
|
+
raise ApiError("model detail is missing usage identity")
|
|
35
|
+
raw = self._parse_headers(detail.get("requiredHeaders") or detail.get("required_headers"))
|
|
36
|
+
normalized = {name.lower(): value for name, value in raw.items()}
|
|
37
|
+
headers = {}
|
|
38
|
+
for name in IDENTITY_HEADERS:
|
|
39
|
+
value = normalized.get(name.lower())
|
|
40
|
+
if not value:
|
|
41
|
+
raise ApiError(f"model detail is missing {name}")
|
|
42
|
+
headers[name] = value
|
|
43
|
+
expiry = detail.get("usageContextExpiresAt")
|
|
44
|
+
try:
|
|
45
|
+
parsed = datetime.fromisoformat(str(expiry).replace("Z", "+00:00"))
|
|
46
|
+
if parsed.tzinfo is None:
|
|
47
|
+
raise ValueError("expiry requires timezone")
|
|
48
|
+
expires_at = parsed.timestamp()
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise ApiError("model detail has invalid usageContextExpiresAt") from exc
|
|
51
|
+
return headers, expires_at
|
|
52
|
+
|
|
53
|
+
def current_headers(self, fetch_detail: Callable[[], dict[str, Any] | None]) -> dict[str, str]:
|
|
54
|
+
with self._lock:
|
|
55
|
+
if self._expires_at <= time.time() + REFRESH_LEAD_SECS:
|
|
56
|
+
headers, expires_at = self._parse(fetch_detail())
|
|
57
|
+
if expires_at <= time.time() + REFRESH_LEAD_SECS:
|
|
58
|
+
raise ApiError("renewed model usage identity expires before the request can start")
|
|
59
|
+
self._headers, self._expires_at = headers, expires_at
|
|
60
|
+
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.
|
|
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,179 @@
|
|
|
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
|
+
IDENTITY_HEADERS[4]: "prod",
|
|
30
|
+
"X-Provider-Unrelated": "must-not-merge",
|
|
31
|
+
"X-Licos-Owner-User-Id": "must-not-replace",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def endpoint(expires_at: float) -> model.ModelEndpoint:
|
|
37
|
+
return model._with_usage_identity(
|
|
38
|
+
model.ModelEndpoint(
|
|
39
|
+
provider="bailian", capability="videoGeneration", model="qwen-test",
|
|
40
|
+
base_url="http://gateway.example/model",
|
|
41
|
+
required_headers={IDENTITY_HEADERS[0].lower(): "catalog-stale", "X-Provider-Route": "route"},
|
|
42
|
+
endpoint={"async": True, "taskQueryUrl": "http://gateway.example/tasks/{taskId}"},
|
|
43
|
+
),
|
|
44
|
+
detail("initial", expires_at),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UsageIdentityTests(unittest.TestCase):
|
|
49
|
+
def setUp(self) -> None:
|
|
50
|
+
self.runtime = model.ModelRuntime(
|
|
51
|
+
base_url="http://platform.example", token="user-token", workspace_id="workspace-1",
|
|
52
|
+
user_id="owner-1", current_user_id="caller-1", owner_project_id="project-1",
|
|
53
|
+
runtime_env="deploy",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def test_valid_lease_reuses_signed_snapshot(self) -> None:
|
|
57
|
+
with mock.patch("time.time", return_value=100):
|
|
58
|
+
lease = UsageIdentityLease(detail("initial", 200), model._parse_required_headers)
|
|
59
|
+
fetch = mock.Mock()
|
|
60
|
+
snapshot = lease.current_headers(fetch)
|
|
61
|
+
snapshot[IDENTITY_HEADERS[0]] = "mutated"
|
|
62
|
+
self.assertEqual(lease.current_headers(fetch)[IDENTITY_HEADERS[0]], "initial")
|
|
63
|
+
fetch.assert_not_called()
|
|
64
|
+
|
|
65
|
+
def test_concurrent_requests_share_one_near_expiry_refresh(self) -> None:
|
|
66
|
+
with mock.patch("time.time", return_value=100):
|
|
67
|
+
lease = UsageIdentityLease(detail("initial", 120), model._parse_required_headers)
|
|
68
|
+
fetch = mock.Mock(return_value=detail("renewed", 200))
|
|
69
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
70
|
+
snapshots = list(executor.map(lambda _: lease.current_headers(fetch), range(16)))
|
|
71
|
+
fetch.assert_called_once()
|
|
72
|
+
self.assertTrue(all(value[IDENTITY_HEADERS[0]] == "renewed" for value in snapshots))
|
|
73
|
+
|
|
74
|
+
def test_invalid_signed_detail_is_rejected(self) -> None:
|
|
75
|
+
for missing in (*IDENTITY_HEADERS[1:], "usageContextExpiresAt"):
|
|
76
|
+
invalid = detail("initial", 200)
|
|
77
|
+
if missing == "usageContextExpiresAt":
|
|
78
|
+
del invalid[missing]
|
|
79
|
+
else:
|
|
80
|
+
del invalid["requiredHeaders"][missing]
|
|
81
|
+
with self.subTest(missing=missing), self.assertRaises(ApiError):
|
|
82
|
+
UsageIdentityLease(invalid, model._parse_required_headers)
|
|
83
|
+
|
|
84
|
+
def test_expired_renewal_never_sends_model_request(self) -> None:
|
|
85
|
+
with mock.patch("time.time", return_value=100):
|
|
86
|
+
selected = endpoint(90)
|
|
87
|
+
with mock.patch.object(model, "_fetch_model_detail", return_value=detail("still-expired", 110)), \
|
|
88
|
+
mock.patch.object(model, "_request_json") as request:
|
|
89
|
+
with self.assertRaisesRegex(ApiError, "expires before"):
|
|
90
|
+
model._post_model_json(selected, self.runtime, {"model": "qwen-test"})
|
|
91
|
+
request.assert_not_called()
|
|
92
|
+
|
|
93
|
+
def test_refresh_failure_never_sends_model_request(self) -> None:
|
|
94
|
+
with mock.patch("time.time", return_value=100):
|
|
95
|
+
selected = endpoint(90)
|
|
96
|
+
with mock.patch.object(model, "_fetch_model_detail", side_effect=ApiError("catalog unavailable")), \
|
|
97
|
+
mock.patch.object(model, "_request_json") as request:
|
|
98
|
+
with self.assertRaisesRegex(ApiError, "catalog unavailable"):
|
|
99
|
+
model._post_model_json(selected, self.runtime, {"model": "qwen-test"})
|
|
100
|
+
request.assert_not_called()
|
|
101
|
+
|
|
102
|
+
def test_submit_replaces_trusted_headers_and_retains_owner_and_caller(self) -> None:
|
|
103
|
+
with mock.patch("time.time", return_value=100):
|
|
104
|
+
selected = endpoint(120)
|
|
105
|
+
with mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 200)) as fetch, \
|
|
106
|
+
mock.patch.object(model, "_request_json", return_value={}) as request:
|
|
107
|
+
for _ in range(2):
|
|
108
|
+
model._post_model_json(selected, self.runtime, {"model": "qwen-test"})
|
|
109
|
+
fetch.assert_called_once_with(self.runtime, "qwen-test", refresh=True)
|
|
110
|
+
headers = request.call_args.kwargs["headers"]
|
|
111
|
+
self.assertEqual(headers[IDENTITY_HEADERS[0]], "renewed")
|
|
112
|
+
self.assertEqual(headers[IDENTITY_HEADERS[4]], "prod")
|
|
113
|
+
self.assertNotIn(IDENTITY_HEADERS[0].lower(), headers)
|
|
114
|
+
self.assertEqual(headers["X-Licos-Owner-User-Id"], "owner-1")
|
|
115
|
+
self.assertEqual(headers["X-Licos-Current-User-Id"], "caller-1")
|
|
116
|
+
self.assertEqual(headers["X-Provider-Route"], "route")
|
|
117
|
+
self.assertEqual(headers["X-DashScope-Async"], "enable")
|
|
118
|
+
self.assertNotIn("X-Provider-Unrelated", headers)
|
|
119
|
+
|
|
120
|
+
def test_media_poll_refreshes_after_submit_identity_nears_expiry(self) -> None:
|
|
121
|
+
clock = [100]
|
|
122
|
+
with mock.patch("time.time", side_effect=lambda: clock[0]):
|
|
123
|
+
selected = endpoint(200)
|
|
124
|
+
captured = []
|
|
125
|
+
|
|
126
|
+
def request(method, url, **kwargs):
|
|
127
|
+
captured.append(kwargs["headers"])
|
|
128
|
+
return {"output": {"task_status": "RUNNING" if len(captured) == 1 else "SUCCEEDED"}}
|
|
129
|
+
|
|
130
|
+
with mock.patch.object(model, "_request_json", side_effect=request), \
|
|
131
|
+
mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 500)) as fetch, \
|
|
132
|
+
mock.patch.object(model, "_enqueue_model_usage"), \
|
|
133
|
+
mock.patch("time.sleep", side_effect=lambda _: clock.__setitem__(0, 180)):
|
|
134
|
+
model._await_async_model_result(selected, self.runtime, {"output": {"task_id": "task-1"}})
|
|
135
|
+
fetch.assert_called_once_with(self.runtime, "qwen-test", refresh=True)
|
|
136
|
+
self.assertEqual([headers[IDENTITY_HEADERS[0]] for headers in captured], ["initial", "renewed"])
|
|
137
|
+
self.assertTrue(all(headers["X-Licos-Current-User-Id"] == "caller-1" for headers in captured))
|
|
138
|
+
|
|
139
|
+
def test_langchain_sync_hook_renews_static_default_headers(self) -> None:
|
|
140
|
+
with mock.patch("time.time", return_value=100):
|
|
141
|
+
handler = model._LangChainUsageHandler(object, self.runtime, endpoint(90))
|
|
142
|
+
req = httpx.Request("POST", "http://gateway.example/model", headers={
|
|
143
|
+
IDENTITY_HEADERS[0].lower(): "stale", **model._routing_headers(self.runtime),
|
|
144
|
+
}, json={"model": "qwen-test"})
|
|
145
|
+
with mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 200)) as fetch:
|
|
146
|
+
handler.on_http_request(req)
|
|
147
|
+
handler.on_http_request(req)
|
|
148
|
+
fetch.assert_called_once()
|
|
149
|
+
self.assertEqual(req.headers.get_list(IDENTITY_HEADERS[0]), ["renewed"])
|
|
150
|
+
self.assertEqual(req.headers["X-Licos-Owner-User-Id"], "owner-1")
|
|
151
|
+
|
|
152
|
+
def test_langchain_async_hooks_share_renewal(self) -> None:
|
|
153
|
+
with mock.patch("time.time", return_value=100):
|
|
154
|
+
handler = model._LangChainUsageHandler(object, self.runtime, endpoint(90))
|
|
155
|
+
requests = [httpx.Request("POST", "http://gateway.example/model", json={"model": "qwen-test"},
|
|
156
|
+
headers=model._routing_headers(self.runtime)) for _ in range(8)]
|
|
157
|
+
|
|
158
|
+
async def run():
|
|
159
|
+
await asyncio.gather(*(handler.on_async_http_request(req) for req in requests))
|
|
160
|
+
|
|
161
|
+
with mock.patch.object(model, "_fetch_model_detail", return_value=detail("renewed", 200)) as fetch:
|
|
162
|
+
asyncio.run(run())
|
|
163
|
+
fetch.assert_called_once()
|
|
164
|
+
self.assertTrue(all(req.headers[IDENTITY_HEADERS[0]] == "renewed" for req in requests))
|
|
165
|
+
|
|
166
|
+
def test_shared_http_client_hooks_do_not_replace_other_user_or_model_signatures(self) -> None:
|
|
167
|
+
with mock.patch("time.time", return_value=100):
|
|
168
|
+
handler = model._LangChainUsageHandler(object, self.runtime, endpoint(90))
|
|
169
|
+
for body, headers in [
|
|
170
|
+
({"model": "another-model"}, model._routing_headers(self.runtime)),
|
|
171
|
+
({"model": "qwen-test"}, {**model._routing_headers(self.runtime), "X-Licos-Current-User-Id": "another-user"}),
|
|
172
|
+
]:
|
|
173
|
+
with self.subTest(body=body, headers=headers):
|
|
174
|
+
req = httpx.Request("POST", "http://gateway.example/model", json=body,
|
|
175
|
+
headers={**headers, IDENTITY_HEADERS[0]: "other-context"})
|
|
176
|
+
with mock.patch.object(model, "_fetch_model_detail") as fetch:
|
|
177
|
+
handler.on_http_request(req)
|
|
178
|
+
fetch.assert_not_called()
|
|
179
|
+
self.assertEqual(req.headers[IDENTITY_HEADERS[0]], "other-context")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{licos_dev_sdk-0.4.6 → licos_dev_sdk-0.4.8}/src/licos_dev_sdk/document_docx_table_actions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|