From 9d4054c893a8c95309e63e82ec94d25075ca4c6d Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Tue, 6 Oct 2026 12:59:06 +0800 Subject: [PATCH 1/3] docs: add LangChain compatible chat POC --- examples/langchain/README.md | 13 +++++++++++++ examples/langchain/chat.py | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 examples/langchain/README.md create mode 100644 examples/langchain/chat.py diff --git a/examples/langchain/README.md b/examples/langchain/README.md new file mode 100644 index 00000000..3d1711f0 --- /dev/null +++ b/examples/langchain/README.md @@ -0,0 +1,13 @@ +# LangChain chat POC + +Use the existing `langchain-openai` package to call AceDataCloud's OpenAI-compatible Chat Completions API. This example fixes the request mode to Chat Completions because LangChain can otherwise infer the Responses API from a model name. It disables automatic retries so a failed request is not silently repeated and billed again. + +```bash +python3 -m pip install langchain-openai +export ACEDATACLOUD_API_TOKEN='your-token' +python3 chat.py 'Summarize this text in one sentence.' +``` + +The default endpoint is `https://api.acedata.cloud/v1` and the default model is `gpt-5.5`. Set `ACEDATACLOUD_MODEL` to a currently supported chat model if needed. Keep the token in an environment variable, never in the script or a repository. Each run makes one billable API request. The endpoint can be changed with `ACEDATACLOUD_BASE_URL` for a local mock or a staging environment. + +This POC uses LangChain's existing adapter. A separate AceDataCloud LangChain package is only useful if it adds typed provider operations or task lifecycle handling that the OpenAI-compatible adapter cannot provide. diff --git a/examples/langchain/chat.py b/examples/langchain/chat.py new file mode 100644 index 00000000..7ebf60a1 --- /dev/null +++ b/examples/langchain/chat.py @@ -0,0 +1,26 @@ +"""Minimal LangChain chat call through AceDataCloud's OpenAI-compatible API.""" + +import os +import sys + +from langchain_openai import ChatOpenAI + + +def main() -> None: + token = os.environ["ACEDATACLOUD_API_TOKEN"] + prompt = sys.argv[1] if len(sys.argv) > 1 else "Reply with one short sentence." + model = ChatOpenAI( + model=os.environ.get("ACEDATACLOUD_MODEL", "gpt-5.5"), + api_key=token, + base_url=os.environ.get("ACEDATACLOUD_BASE_URL", "https://api.acedata.cloud/v1"), + use_responses_api=False, + stream_usage=False, + max_retries=0, + timeout=60, + ) + response = model.invoke([("user", prompt)]) + print(response.content) + + +if __name__ == "__main__": + main() From 2d02e36b510a86f034ed94f355c832537a90d394 Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Tue, 6 Oct 2026 21:14:13 +0800 Subject: [PATCH 2/3] examples: verify LangChain ChatOpenAI integration --- .github/workflows/ci.yaml | 21 +++- README.md | 5 + examples/langchain/README.md | 64 ++++++++-- examples/langchain/chat.py | 112 +++++++++++++++-- examples/langchain/test_chat.py | 213 ++++++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 examples/langchain/test_chat.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 85f247f8..eac65fab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,10 +57,27 @@ jobs: pip install ".[dev]" - name: Run Ruff linter - run: ruff check . + run: ruff check . ../examples/langchain - name: Run Ruff formatter check - run: ruff format --check . + run: ruff format --check . ../examples/langchain + + langchain-example: + name: LangChain Example + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install example dependencies + run: python -m pip install 'langchain-openai==1.6.6' pytest + + - name: Test ChatOpenAI protocol + run: python -m pytest examples/langchain/test_chat.py -q python-test: name: Python Test (${{ matrix.python-version }}) diff --git a/README.md b/README.md index bc823163..f24deab7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ Official SDK clients for the [AceDataCloud API](https://platform.acedata.cloud). | TypeScript | [typescript](typescript/) | `@acedatacloud/sdk` | | Go | [go](go/) | `github.com/AceDataCloud/SDK/go` | +## Framework examples + +- [LangChain chat](examples/langchain/): use `langchain-openai` with the + OpenAI-compatible Chat Completions endpoint for text, streaming, and tools. + ## Architecture All three SDKs share the same design, organised along **two axes**: diff --git a/examples/langchain/README.md b/examples/langchain/README.md index 3d1711f0..bbb007c1 100644 --- a/examples/langchain/README.md +++ b/examples/langchain/README.md @@ -1,13 +1,63 @@ -# LangChain chat POC +# LangChain chat with Ace Data Cloud -Use the existing `langchain-openai` package to call AceDataCloud's OpenAI-compatible Chat Completions API. This example fixes the request mode to Chat Completions because LangChain can otherwise infer the Responses API from a model name. It disables automatic retries so a failed request is not silently repeated and billed again. +Ace Data Cloud's `/v1/chat/completions` endpoint works with LangChain's existing +[`ChatOpenAI`](https://docs.langchain.com/oss/python/integrations/chat/openai) +adapter. No separate package is needed for standard text, streaming, and tool +calling. `ChatOpenAI` only preserves standard OpenAI response fields; use a +provider-specific API when you need nonstandard fields or operations. + +## Run ```bash -python3 -m pip install langchain-openai -export ACEDATACLOUD_API_TOKEN='your-token' -python3 chat.py 'Summarize this text in one sentence.' +python3 -m pip install 'langchain-openai>=1.6,<2' +export ACEDATACLOUD_API_TOKEN='your Ace Data Cloud token' +python3 examples/langchain/chat.py 'Reply with one short sentence.' +python3 examples/langchain/chat.py --mode stream 'Count from one to three.' +python3 examples/langchain/chat.py --mode tools ``` -The default endpoint is `https://api.acedata.cloud/v1` and the default model is `gpt-5.5`. Set `ACEDATACLOUD_MODEL` to a currently supported chat model if needed. Keep the token in an environment variable, never in the script or a repository. Each run makes one billable API request. The endpoint can be changed with `ACEDATACLOUD_BASE_URL` for a local mock or a staging environment. +Run these commands from the SDK repository root. Get a token from the +[Ace Data Cloud console](https://platform.acedata.cloud/console/applications). +The default model is `gpt-4o-mini`; set `ACEDATACLOUD_MODEL` to another model +available to your account. `ACEDATACLOUD_BASE_URL` defaults to +`https://api.acedata.cloud/v1` and can point to a local mock or staging endpoint. +Do not include `/chat/completions` in the base URL. + +The example fixes `use_responses_api=False`, since some model names cause +LangChain to choose the Responses API. It sets `max_retries=0`, so an error is +not automatically submitted again. The `chat` and `stream` modes each make one +billable request; `tools` makes two (tool selection and the final answer). The +local `add_numbers` function has no external side effects. Any automatic retry +or agent loop you add can create more billable requests. + +The example prints the API response ID and `usage_metadata` token counts when +available. Keep the response ID for troubleshooting. Token counts are not the +charged Credits; check the +[Usage History](https://platform.acedata.cloud/console/usages) for the actual +charge. Streaming usage is off by default for compatibility. Use +`--mode stream --stream-usage` with a model that supports OpenAI's +`stream_options.include_usage` to request final token counts; if the service +does not return them, check Usage History. + +| Result | What to check | +| --- | --- | +| `401` | Token value and permissions. Pass the bare token; the adapter adds `Bearer`. | +| `403` | Balance, access, or content policy shown in the response. | +| `429` | Rate limit. Wait before an intentional retry. | +| `404` | Base URL must include `/v1`; confirm the model ID. | +| `5xx` or timeout | Check service status and Usage History before retrying an uncertain request. | + +## Verify without a billable call + +With `pytest` installed, run: + +```bash +python3 -m pytest examples/langchain/test_chat.py -q +``` -This POC uses LangChain's existing adapter. A separate AceDataCloud LangChain package is only useful if it adds typed provider operations or task lifecycle handling that the OpenAI-compatible adapter cannot provide. +The tests use the installed LangChain and OpenAI SDK with a local HTTP mock. +They check URL, bearer authorization, Chat Completions request mode, response +and token usage parsing, streaming, tool round trip, and errors without making +a network request. They do not establish live account access, provider behavior, +or billing. Complete those checks with a valid token before claiming live +availability or seeking an upstream LangChain listing. diff --git a/examples/langchain/chat.py b/examples/langchain/chat.py index 7ebf60a1..3c5d76f7 100644 --- a/examples/langchain/chat.py +++ b/examples/langchain/chat.py @@ -1,25 +1,117 @@ -"""Minimal LangChain chat call through AceDataCloud's OpenAI-compatible API.""" +"""Call Ace Data Cloud Chat Completions with LangChain's ChatOpenAI.""" +import argparse +import json import os import sys +from langchain_core.messages import HumanMessage, ToolMessage from langchain_openai import ChatOpenAI +BASE_URL = "https://api.acedata.cloud/v1" +DEFAULT_MODEL = "gpt-4o-mini" +ADD_TOOL = { + "type": "function", + "function": { + "name": "add_numbers", + "description": "Add two integers.", + "parameters": { + "type": "object", + "properties": { + "a": {"type": "integer"}, + "b": {"type": "integer"}, + }, + "required": ["a", "b"], + "additionalProperties": False, + }, + }, +} -def main() -> None: - token = os.environ["ACEDATACLOUD_API_TOKEN"] - prompt = sys.argv[1] if len(sys.argv) > 1 else "Reply with one short sentence." - model = ChatOpenAI( - model=os.environ.get("ACEDATACLOUD_MODEL", "gpt-5.5"), + +def build_model(*, stream_usage: bool = False, **client_options) -> ChatOpenAI: + token = os.environ.get("ACEDATACLOUD_API_TOKEN") + if not token: + raise SystemExit("Set ACEDATACLOUD_API_TOKEN before running this example.") + return ChatOpenAI( + model=os.environ.get("ACEDATACLOUD_MODEL", DEFAULT_MODEL), api_key=token, - base_url=os.environ.get("ACEDATACLOUD_BASE_URL", "https://api.acedata.cloud/v1"), + base_url=os.environ.get("ACEDATACLOUD_BASE_URL", BASE_URL), use_responses_api=False, - stream_usage=False, + stream_usage=stream_usage, max_retries=0, timeout=60, + **client_options, + ) + + +def show_usage(message) -> None: + response_id = message.response_metadata.get("id") + if response_id: + print(f"response id: {response_id}", file=sys.stderr) + usage = message.usage_metadata + if usage: + print(f"tokens: {json.dumps(usage, sort_keys=True)}", file=sys.stderr) + else: + print( + "token usage unavailable; check Usage History for the charge", + file=sys.stderr, + ) + + +def run_chat(model: ChatOpenAI, prompt: str) -> None: + reply = model.invoke([HumanMessage(content=prompt)]) + print(reply.content) + show_usage(reply) + + +def run_stream(model: ChatOpenAI, prompt: str) -> None: + complete = None + for chunk in model.stream([HumanMessage(content=prompt)]): + if isinstance(chunk.content, str): + print(chunk.content, end="", flush=True) + complete = chunk if complete is None else complete + chunk + print() + if complete is not None: + show_usage(complete) + + +def run_tools(model: ChatOpenAI) -> None: + messages = [ + HumanMessage(content="Use add_numbers to add 2 and 3, then report the sum.") + ] + tool_reply = model.bind_tools([ADD_TOOL], tool_choice="required").invoke(messages) + if not tool_reply.tool_calls: + raise RuntimeError("The model returned no tool call.") + show_usage(tool_reply) + messages.append(tool_reply) + for call in tool_reply.tool_calls: + if call["name"] != "add_numbers": + raise RuntimeError(f"Unexpected tool: {call['name']}") + args = call["args"] + result = int(args["a"]) + int(args["b"]) + messages.append(ToolMessage(content=str(result), tool_call_id=call["id"])) + final = model.invoke(messages) + print(final.content) + show_usage(final) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("prompt", nargs="?", default="Reply with one short sentence.") + parser.add_argument("--mode", choices=("chat", "stream", "tools"), default="chat") + parser.add_argument( + "--stream-usage", + action="store_true", + help="Request a final token usage chunk when streaming (model support varies).", ) - response = model.invoke([("user", prompt)]) - print(response.content) + args = parser.parse_args() + model = build_model(stream_usage=args.stream_usage) + if args.mode == "chat": + run_chat(model, args.prompt) + elif args.mode == "stream": + run_stream(model, args.prompt) + else: + run_tools(model) if __name__ == "__main__": diff --git a/examples/langchain/test_chat.py b/examples/langchain/test_chat.py new file mode 100644 index 00000000..9ef2432f --- /dev/null +++ b/examples/langchain/test_chat.py @@ -0,0 +1,213 @@ +"""Protocol checks using the real LangChain adapter and an in-memory HTTP mock.""" + +import asyncio +import json + +import httpx +import pytest +from chat import build_model, run_chat, run_stream, run_tools +from langchain_core.messages import HumanMessage +from openai import ( + AuthenticationError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, +) + + +def completion(message, *, model="gpt-4o-mini", finish_reason="stop", usage=None): + return { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1, + "model": model, + "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}], + "usage": usage + or {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +def assert_request(request, *, model="gpt-4o-mini"): + assert request.method == "POST" + assert request.url.path == "/v1/chat/completions" + assert request.headers["authorization"] == "Bearer test-token" + body = json.loads(request.content) + assert body["model"] == model + return body + + +@pytest.fixture(autouse=True) +def credentials(monkeypatch): + monkeypatch.setenv("ACEDATACLOUD_API_TOKEN", "test-token") + monkeypatch.delenv("ACEDATACLOUD_BASE_URL", raising=False) + monkeypatch.delenv("ACEDATACLOUD_MODEL", raising=False) + monkeypatch.setenv("LANGSMITH_TRACING", "false") + monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false") + + +@pytest.mark.parametrize("model_name", ["gpt-4o-mini", "gpt-5.5"]) +def test_sync_request_and_usage(capsys, monkeypatch, model_name): + monkeypatch.setenv("ACEDATACLOUD_MODEL", model_name) + requests = [] + + def handle(request): + requests.append(assert_request(request, model=model_name)) + return httpx.Response( + 200, + json=completion( + {"role": "assistant", "content": "Hello"}, model=model_name + ), + ) + + client = httpx.Client(transport=httpx.MockTransport(handle)) + run_chat(build_model(http_client=client), "Say hello") + out = capsys.readouterr() + assert out.out.strip() == "Hello" + assert '"total_tokens": 8' in out.err + assert len(requests) == 1 + assert requests[0]["messages"] == [{"content": "Say hello", "role": "user"}] + assert not requests[0].get("stream") + + +def test_stream_request_and_final_usage(capsys): + requests = [] + + def event(data): + return f"data: {json.dumps(data)}\n\n" + + chunks = [ + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "Hi"}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "delta": {"content": "!"}, "finish_reason": "stop"} + ], + }, + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [], + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6}, + }, + ] + + def handle(request): + requests.append(assert_request(request)) + stream = "".join(event(chunk) for chunk in chunks) + "data: [DONE]\n\n" + return httpx.Response( + 200, text=stream, headers={"content-type": "text/event-stream"} + ) + + client = httpx.Client(transport=httpx.MockTransport(handle)) + run_stream(build_model(stream_usage=True, http_client=client), "Say hi") + out = capsys.readouterr() + assert out.out.strip() == "Hi!" + assert '"total_tokens": 6' in out.err + assert len(requests) == 1 + assert requests[0]["stream"] is True + assert requests[0]["stream_options"] == {"include_usage": True} + + +def test_tool_call_round_trip(capsys): + requests = [] + + def handle(request): + body = assert_request(request) + requests.append(body) + if len(requests) == 1: + message = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_add", + "type": "function", + "function": { + "name": "add_numbers", + "arguments": '{"a":2,"b":3}', + }, + } + ], + } + return httpx.Response( + 200, json=completion(message, finish_reason="tool_calls") + ) + return httpx.Response( + 200, json=completion({"role": "assistant", "content": "5"}) + ) + + client = httpx.Client(transport=httpx.MockTransport(handle)) + run_tools(build_model(http_client=client)) + out = capsys.readouterr() + assert out.out.strip() == "5" + assert out.err.count("tokens:") == 2 + assert len(requests) == 2 + assert requests[0]["tool_choice"] == "required" + assert requests[0]["tools"][0]["function"]["name"] == "add_numbers" + assert requests[1]["messages"][-1] == { + "content": "5", + "role": "tool", + "tool_call_id": "call_add", + } + + +@pytest.mark.parametrize( + "status,error_type", + [ + (401, AuthenticationError), + (403, PermissionDeniedError), + (404, NotFoundError), + (429, RateLimitError), + (500, InternalServerError), + ], +) +def test_http_error_does_not_retry(status, error_type): + attempts = 0 + + def handle(request): + nonlocal attempts + assert_request(request) + attempts += 1 + return httpx.Response( + status, json={"error": {"message": "test error", "type": "test_error"}} + ) + + client = httpx.Client(transport=httpx.MockTransport(handle)) + with pytest.raises(error_type): + build_model(http_client=client).invoke([HumanMessage(content="hello")]) + assert attempts == 1 + + +def test_async_chat_completions(): + requests = [] + + def handle(request): + requests.append(assert_request(request)) + return httpx.Response( + 200, json=completion({"role": "assistant", "content": "Async OK"}) + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + model = build_model(http_async_client=client) + reply = asyncio.run(model.ainvoke([HumanMessage(content="hello")])) + assert reply.content == "Async OK" + assert reply.usage_metadata["total_tokens"] == 8 + assert len(requests) == 1 From a4513f852d42d94c3b935e4189a0984c8e2c1671 Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Tue, 6 Oct 2026 21:27:19 +0800 Subject: [PATCH 3/3] style: match SDK Ruff formatting for LangChain example --- examples/langchain/chat.py | 4 +--- examples/langchain/test_chat.py | 31 ++++++++----------------------- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/examples/langchain/chat.py b/examples/langchain/chat.py index 3c5d76f7..84f3491c 100644 --- a/examples/langchain/chat.py +++ b/examples/langchain/chat.py @@ -76,9 +76,7 @@ def run_stream(model: ChatOpenAI, prompt: str) -> None: def run_tools(model: ChatOpenAI) -> None: - messages = [ - HumanMessage(content="Use add_numbers to add 2 and 3, then report the sum.") - ] + messages = [HumanMessage(content="Use add_numbers to add 2 and 3, then report the sum.")] tool_reply = model.bind_tools([ADD_TOOL], tool_choice="required").invoke(messages) if not tool_reply.tool_calls: raise RuntimeError("The model returned no tool call.") diff --git a/examples/langchain/test_chat.py b/examples/langchain/test_chat.py index 9ef2432f..9e30244b 100644 --- a/examples/langchain/test_chat.py +++ b/examples/langchain/test_chat.py @@ -23,8 +23,7 @@ def completion(message, *, model="gpt-4o-mini", finish_reason="stop", usage=None "created": 1, "model": model, "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}], - "usage": usage - or {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + "usage": usage or {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, } @@ -55,9 +54,7 @@ def handle(request): requests.append(assert_request(request, model=model_name)) return httpx.Response( 200, - json=completion( - {"role": "assistant", "content": "Hello"}, model=model_name - ), + json=completion({"role": "assistant", "content": "Hello"}, model=model_name), ) client = httpx.Client(transport=httpx.MockTransport(handle)) @@ -95,9 +92,7 @@ def event(data): "object": "chat.completion.chunk", "created": 1, "model": "gpt-4o-mini", - "choices": [ - {"index": 0, "delta": {"content": "!"}, "finish_reason": "stop"} - ], + "choices": [{"index": 0, "delta": {"content": "!"}, "finish_reason": "stop"}], }, { "id": "chatcmpl-test", @@ -112,9 +107,7 @@ def event(data): def handle(request): requests.append(assert_request(request)) stream = "".join(event(chunk) for chunk in chunks) + "data: [DONE]\n\n" - return httpx.Response( - 200, text=stream, headers={"content-type": "text/event-stream"} - ) + return httpx.Response(200, text=stream, headers={"content-type": "text/event-stream"}) client = httpx.Client(transport=httpx.MockTransport(handle)) run_stream(build_model(stream_usage=True, http_client=client), "Say hi") @@ -147,12 +140,8 @@ def handle(request): } ], } - return httpx.Response( - 200, json=completion(message, finish_reason="tool_calls") - ) - return httpx.Response( - 200, json=completion({"role": "assistant", "content": "5"}) - ) + return httpx.Response(200, json=completion(message, finish_reason="tool_calls")) + return httpx.Response(200, json=completion({"role": "assistant", "content": "5"})) client = httpx.Client(transport=httpx.MockTransport(handle)) run_tools(build_model(http_client=client)) @@ -186,9 +175,7 @@ def handle(request): nonlocal attempts assert_request(request) attempts += 1 - return httpx.Response( - status, json={"error": {"message": "test error", "type": "test_error"}} - ) + return httpx.Response(status, json={"error": {"message": "test error", "type": "test_error"}}) client = httpx.Client(transport=httpx.MockTransport(handle)) with pytest.raises(error_type): @@ -201,9 +188,7 @@ def test_async_chat_completions(): def handle(request): requests.append(assert_request(request)) - return httpx.Response( - 200, json=completion({"role": "assistant", "content": "Async OK"}) - ) + return httpx.Response(200, json=completion({"role": "assistant", "content": "Async OK"})) client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) model = build_model(http_async_client=client)