diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 565c50a..a98e817 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,10 +87,34 @@ 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 (${{ matrix.adapter-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + adapter-version: ["1.6.6", "1.7.0"] + defaults: + run: + working-directory: examples/langchain + 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==${{ matrix.adapter-version }}' pytest + + - name: Test ChatOpenAI protocol + run: python -m pytest -q python-test: name: Python Test (${{ matrix.python-version }}) diff --git a/README.md b/README.md index c48a9d0..2985023 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ Use this application key for API requests. SDK account-management methods requir | 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 new file mode 100644 index 0000000..906c964 --- /dev/null +++ b/examples/langchain/README.md @@ -0,0 +1,95 @@ +# LangChain chat with Ace Data Cloud + +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 + +Use Python 3.10 or newer. Download the example and create a separate environment: + +```bash +git clone https://github.com/AceDataCloud/SDK.git +cd SDK +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install -r examples/langchain/requirements.txt pytest +``` + +On Windows, activate the environment with `.venv\Scripts\Activate.ps1` in +PowerShell. The example pins `langchain-openai==1.7.0`; CI also checks 1.6.6. + +Get an application API key from +[Ace Data Cloud → Applications](https://platform.acedata.cloud/console/applications?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=sdk_langchain_readme_api_key). +Sign in or register, initialize **General application** if needed, and open +**Keys / Manage Keys → Create** to give this integration a separate key. +Check its service access, expiration, allowed APIs, usage limit, and balance. +Use the raw API key, without `Bearer `; a `platform-...` management token cannot +replace it. See the [illustrated key setup](../../README.md#get-your-ace-data-cloud-api-key). + +From the SDK repository root, set the key and run one mode: + +```bash +export ACEDATACLOUD_API_TOKEN='your Ace Data Cloud token' +python3 examples/langchain/chat.py 'Reply with one short sentence.' +``` + +Optional streaming and tool examples each submit additional requests: + +```bash +python3 examples/langchain/chat.py --mode stream 'Count from one to three.' +python3 examples/langchain/chat.py --mode tools +``` + +In PowerShell, set the key with `$env:ACEDATACLOUD_API_TOKEN='your token'`. +The default model is `gpt-4o-mini`; set `ACEDATACLOUD_MODEL` to another model +available to your account in the +[model directory and current pricing](https://platform.acedata.cloud/models?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=sdk_langchain_readme_pricing). +`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?utm_source=opensource&utm_medium=referral&utm_campaign=evergreen&utm_content=sdk_langchain_readme_usage) 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. + +The tool example should print `5` and report usage for its two API calls. +Text appears on stdout; response IDs and available token counts appear on stderr. + +| 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 +cd examples/langchain +python3 -m pytest -q +``` + +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 new file mode 100644 index 0000000..84f3491 --- /dev/null +++ b/examples/langchain/chat.py @@ -0,0 +1,116 @@ +"""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 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", BASE_URL), + use_responses_api=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).", + ) + 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__": + main() diff --git a/examples/langchain/requirements.txt b/examples/langchain/requirements.txt new file mode 100644 index 0000000..8b979ac --- /dev/null +++ b/examples/langchain/requirements.txt @@ -0,0 +1 @@ +langchain-openai==1.7.0 diff --git a/examples/langchain/test_chat.py b/examples/langchain/test_chat.py new file mode 100644 index 0000000..9e30244 --- /dev/null +++ b/examples/langchain/test_chat.py @@ -0,0 +1,198 @@ +"""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