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.
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/PKG-INFO +1 -1
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/pyproject.toml +1 -1
- licos_dev_sdk-0.4.7/src/licos_dev_sdk/_usage_identity.py +59 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/model.py +63 -7
- licos_dev_sdk-0.4.7/tests/test_langchain_protocol.py +154 -0
- licos_dev_sdk-0.4.7/tests/test_usage_identity.py +176 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/.gitignore +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/DOCX.md +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/__init__.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_common.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_edit.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_expectations.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_inspect.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_layout.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_package.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_preservation.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_structure.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_table.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_docx_table_actions.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_read.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/document_table_export.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/pdf_inspect.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation_canvas.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/presentation_inspect.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_document_read.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_document_spreadsheet.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_document_table_export.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_docx_inspect.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_docx_structure.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_docx_table_edit.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_html_docx_features.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_observability.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_output_paths.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_ppt_master_runtime.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_presentation.py +0 -0
- {licos_dev_sdk-0.4.5 → licos_dev_sdk-0.4.7}/tests/test_spreadsheet_html_edit.py +0 -0
|
@@ -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":
|
|
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.
|
|
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
|
|
665
|
+
def _openai_sdk_base_url(url: str, *, use_responses_api: bool) -> str:
|
|
626
666
|
trimmed = str(url or "").strip().rstrip("/")
|
|
627
|
-
|
|
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
|
|
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.5 → licos_dev_sdk-0.4.7}/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
|