From 76d7a37df5eebe0e6a75f11032f60b1d7ac62b4a Mon Sep 17 00:00:00 2001 From: xeondesk Date: Mon, 9 Mar 2026 00:52:40 +0600 Subject: [PATCH 01/10] Create jsonl.py --- src/ctxos/_decoders/jsonl.py | 123 +++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/ctxos/_decoders/jsonl.py diff --git a/src/ctxos/_decoders/jsonl.py b/src/ctxos/_decoders/jsonl.py new file mode 100644 index 0000000..ac5ac74 --- /dev/null +++ b/src/ctxos/_decoders/jsonl.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +from typing_extensions import Generic, TypeVar, Iterator, AsyncIterator + +import httpx + +from .._models import construct_type_unchecked + +_T = TypeVar("_T") + + +class JSONLDecoder(Generic[_T]): + """A decoder for [JSON Lines](https://jsonlines.org) format. + + This class provides an iterator over a byte-iterator that parses each JSON Line + into a given type. + """ + + http_response: httpx.Response + """The HTTP response this decoder was constructed from""" + + def __init__( + self, + *, + raw_iterator: Iterator[bytes], + line_type: type[_T], + http_response: httpx.Response, + ) -> None: + super().__init__() + self.http_response = http_response + self._raw_iterator = raw_iterator + self._line_type = line_type + self._iterator = self.__decode__() + + def close(self) -> None: + """Close the response body stream. + + This is called automatically if you consume the entire stream. + """ + self.http_response.close() + + def __decode__(self) -> Iterator[_T]: + buf = b"" + for chunk in self._raw_iterator: + for line in chunk.splitlines(keepends=True): + buf += line + if buf.endswith((b"\r", b"\n", b"\r\n")): + yield construct_type_unchecked( + value=json.loads(buf), + type_=self._line_type, + ) + buf = b"" + + # flush + if buf: + yield construct_type_unchecked( + value=json.loads(buf), + type_=self._line_type, + ) + + def __next__(self) -> _T: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[_T]: + for item in self._iterator: + yield item + + +class AsyncJSONLDecoder(Generic[_T]): + """A decoder for [JSON Lines](https://jsonlines.org) format. + + This class provides an async iterator over a byte-iterator that parses each JSON Line + into a given type. + """ + + http_response: httpx.Response + + def __init__( + self, + *, + raw_iterator: AsyncIterator[bytes], + line_type: type[_T], + http_response: httpx.Response, + ) -> None: + super().__init__() + self.http_response = http_response + self._raw_iterator = raw_iterator + self._line_type = line_type + self._iterator = self.__decode__() + + async def close(self) -> None: + """Close the response body stream. + + This is called automatically if you consume the entire stream. + """ + await self.http_response.aclose() + + async def __decode__(self) -> AsyncIterator[_T]: + buf = b"" + async for chunk in self._raw_iterator: + for line in chunk.splitlines(keepends=True): + buf += line + if buf.endswith((b"\r", b"\n", b"\r\n")): + yield construct_type_unchecked( + value=json.loads(buf), + type_=self._line_type, + ) + buf = b"" + + # flush + if buf: + yield construct_type_unchecked( + value=json.loads(buf), + type_=self._line_type, + ) + + async def __anext__(self) -> _T: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[_T]: + async for item in self._iterator: + yield item From dd4cbb93448e701b8b691c985202ff637de54cbe Mon Sep 17 00:00:00 2001 From: xeondesk Date: Mon, 9 Mar 2026 00:58:33 +0600 Subject: [PATCH 02/10] Update _constants.py --- src/ctxos/_constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ctxos/_constants.py b/src/ctxos/_constants.py index 6ddf2c7..bdafd59 100644 --- a/src/ctxos/_constants.py +++ b/src/ctxos/_constants.py @@ -12,3 +12,6 @@ INITIAL_RETRY_DELAY = 0.5 MAX_RETRY_DELAY = 8.0 + +HUMAN_PROMPT = "\n\nHuman:" +AI_PROMPT = "\n\nAssistant:" From ff760e61c257b6523b91ed5d24fb4737e56b910f Mon Sep 17 00:00:00 2001 From: xeondesk Date: Mon, 9 Mar 2026 01:03:56 +0600 Subject: [PATCH 03/10] =?UTF-8?q?Create=20completion.py=E2=80=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- "src/ctxos/types/completion.py\342\200\216" | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 "src/ctxos/types/completion.py\342\200\216" diff --git "a/src/ctxos/types/completion.py\342\200\216" "b/src/ctxos/types/completion.py\342\200\216" new file mode 100644 index 0000000..7457c25 --- /dev/null +++ "b/src/ctxos/types/completion.py\342\200\216" @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. + +from .._models import BaseModel + +__all__ = ["Completion"] + + +class Completion(BaseModel): + completion: str + """The resulting completion up to and excluding the stop sequences.""" + + model: str + """The model that performed the completion.""" + + stop_reason: str + """The reason that we stopped sampling. + + This may be one the following values: + + - `"stop_sequence"`: we reached a stop sequence — either provided by you via the + `stop_sequences` parameter, or a stop sequence built into the model + - `"max_tokens"`: we exceeded `max_tokens_to_sample` or the model's maximum + """ From 131c1b3691fc007505c074d70205913980bc179e Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:57:37 +0000 Subject: [PATCH 04/10] Add some length checks on the prompts and max-samples, to ensure they're within our acceptable range --- README.md | 395 ++++++++++++++-------------------- bin/check-test-server | 50 +++++ bin/test | 3 + examples/.keep | 4 - examples/demo_async.py | 20 ++ examples/demo_sync.py | 18 ++ examples/streaming.py | 57 +++++ examples/tokens.py | 32 +++ src/ctxos/_base_exceptions.py | 117 ++++++++++ src/ctxos/_tokenizers.py | 43 ++++ src/ctxos/pagination.py | 7 + uv.lock | 2 +- 12 files changed, 505 insertions(+), 243 deletions(-) create mode 100755 bin/check-test-server create mode 100755 bin/test delete mode 100644 examples/.keep create mode 100644 examples/demo_async.py create mode 100644 examples/demo_sync.py create mode 100644 examples/streaming.py create mode 100644 examples/tokens.py create mode 100644 src/ctxos/_base_exceptions.py create mode 100644 src/ctxos/_tokenizers.py create mode 100644 src/ctxos/pagination.py diff --git a/README.md b/README.md index 2bb7d9a..0576873 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,119 @@ -# Ctxos Python API library +# Ctxos Python API Library - -[![PyPI version](https://img.shields.io/pypi/v/ctxos.svg?label=pypi%20(stable))](https://pypi.org/project/ctxos/) +[![PyPI version](https://img.shields.io/pypi/v/ctxos.svg)](https://pypi.org/project/ctxos/) -The Ctxos Python library provides convenient access to the Ctxos REST API from any Python 3.9+ -application. The library includes type definitions for all request params and response fields, +The Ctxos Python library provides convenient access to the Ctxos REST API from any Python 3.7+ +application. It includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). -It is generated with [Stainless](https://www.stainless.com/). +## Migration from v0.2.x and below -## Documentation +In `v0.3.0`, we introduced a fully rewritten SDK. -The full API of this library can be found in [api.md](api.md). +The new version uses separate sync and async clients, unified streaming, typed params and structured response objects, and resource-oriented methods: + +**Sync before/after:** + +```diff +- client = ctxos.Client(os.environ["CTXOS_API_KEY"]) ++ client = ctxos.Ctxos(api_key=os.environ["CTXOS_API_KEY"]) + # or, simply provide an CTXOS_API_KEY environment variable: ++ client = ctxos.Ctxos() + +- rsp = client.completion(**params) +- rsp["completion"] ++ rsp = client.completions.create(**params) ++ rsp.completion +``` + +**Async before/after:** + +```diff +- client = ctxos.Client(os.environ["CTXOS_API_KEY"]) ++ client = ctxos.AsyncCtxos(api_key=os.environ["CTXOS_API_KEY"]) + +- await client.acompletion(**params) ++ await client.completions.create(**params) +``` + +The `.completion_stream()` and `.acompletion_stream()` methods have been removed; +simply pass `stream=True`to `.completions.create()`. + +
+Example streaming diff + +```diff py + import ctxos + +- client = ctxos.Client(os.environ["CTXOS_API_KEY"]) ++ client = ctxos.Ctxos() + + # Streams are now incremental diffs of text + # rather than sending the whole message every time: + text = " +- stream = client.completion_stream(**params) +- for data in stream: +- diff = data["completion"].replace(text, "") +- text = data["completion"] ++ stream = client.completions.create(**params, stream=True) ++ for data in stream: ++ diff = data.completion # incremental text ++ text += data.completion + print(diff, end="") + + print("Done. Final text is:") + print(text) +``` + +
## Installation ```sh -# install from PyPI pip install ctxos ``` ## Usage -The full API of this library can be found in [api.md](api.md). - ```python -import os -from ctxos import Ctxos +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT -client = Ctxos( - api_key=os.environ.get("CTXOS_API_KEY"), # This is the default and can be omitted +ctxos = Ctxos( + # defaults to os.environ.get("CTXOS_API_KEY") + api_key="my api key", ) -complete = client.complete.create( +completion = ctxos.completions.create( model="ctxos-1", - prompt="prompt", + max_tokens_to_sample=300, + prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court? {AI_PROMPT}", ) -print(complete.id) +print(completion.completion) ``` -While you can provide an `api_key` keyword argument, -we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) -to add `CTXOS_API_KEY="My API Key"` to your `.env` file -so that your API Key is not stored in source control. +While you can provide an `api_key` keyword argument, we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +and adding `CTXOS_API_KEY="my api key"` to your `.env` file so that your API Key is not stored in source control. -## Async usage +## Async Usage Simply import `AsyncCtxos` instead of `Ctxos` and use `await` with each API call: ```python -import os -import asyncio -from ctxos import AsyncCtxos +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT -client = AsyncCtxos( - api_key=os.environ.get("CTXOS_API_KEY"), # This is the default and can be omitted +ctxos = AsyncCtxos( + # defaults to os.environ.get("CTXOS_API_KEY") + api_key="my api key", ) -async def main() -> None: - complete = await client.complete.create( +async def main(): + completion = await ctxos.completions.create( model="ctxos-1", - prompt="prompt", + max_tokens_to_sample=300, + prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court? {AI_PROMPT}", ) - print(complete.id) + print(completion.completion) asyncio.run(main()) @@ -71,69 +121,67 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. -### With aiohttp +## Streaming Responses + +We provide support for streaming responses using Server Side Events (SSE). -By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. +```python +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT -You can enable this by installing `aiohttp`: +ctxos = Ctxos() -```sh -# install from PyPI -pip install ctxos[aiohttp] +stream = ctxos.completions.create( + prompt=f"{HUMAN_PROMPT} Your prompt here {AI_PROMPT}", + max_tokens_to_sample=300, + model="ctxos-1", + stream=True, +) +for completion in stream: + print(completion.completion) ``` -Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: +The async client uses the exact same interface. ```python -import os -import asyncio -from ctxos import DefaultAioHttpClient -from ctxos import AsyncCtxos - - -async def main() -> None: - async with AsyncCtxos( - api_key=os.environ.get("CTXOS_API_KEY"), # This is the default and can be omitted - http_client=DefaultAioHttpClient(), - ) as client: - complete = await client.complete.create( - model="ctxos-1", - prompt="prompt", - ) - print(complete.id) +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +ctxos = AsyncCtxos() -asyncio.run(main()) +stream = await ctxos.completions.create( + prompt=f"{HUMAN_PROMPT} Your prompt here {AI_PROMPT}", + max_tokens_to_sample=300, + model="ctxos-1", + stream=True, +) +async for completion in stream: + print(completion.completion) ``` -## Using types - -Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: +## Using Types -- Serializing back into JSON, `model.to_json()` -- Converting to a dictionary, `model.to_dict()` +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict), while responses are [Pydantic](https://pydantic-docs.helpmanual.io/) models. This helps provide autocomplete and documentation within your editor. -Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `"basic"`. ## Handling errors -When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `ctxos.APIConnectionError` is raised. +When the library is unable to connect to the API (e.g., due to network connection problems or a timeout), a subclass of `ctxos.APIConnectionError` is raised. -When the API returns a non-success status code (that is, 4xx or 5xx -response), a subclass of `ctxos.APIStatusError` is raised, containing `status_code` and `response` properties. +When the API returns a non-success status code (i.e., 4xx or 5xx +response), a subclass of `ctxos.APIStatusError` will be raised, containing `status_code` and `response` properties. All errors inherit from `ctxos.APIError`. ```python -import ctxos -from ctxos import Ctxos +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT -client = Ctxos() +ctxos = Ctxos() try: - client.complete.create( + ctxos.completions.create( + prompt=f"{HUMAN_PROMPT} Your prompt here {AI_PROMPT}", + max_tokens_to_sample=300, model="ctxos-1", - prompt="prompt", ) except ctxos.APIConnectionError as e: print("The server could not be reached") @@ -146,7 +194,7 @@ except ctxos.APIStatusError as e: print(e.response) ``` -Error codes are as follows: +Error codes are as followed: | Status Code | Error Type | | ----------- | -------------------------- | @@ -161,228 +209,99 @@ Error codes are as follows: ### Retries -Certain errors are automatically retried 2 times by default, with a short exponential backoff. -Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, -429 Rate Limit, and >=500 Internal errors are all retried by default. +Certain errors will be automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 409 Conflict, 429 Rate Limit, +and >=500 Internal errors will all be retried by default. -You can use the `max_retries` option to configure or disable retry settings: +You can use the `max_retries` option to configure or disable this: ```python -from ctxos import Ctxos +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT # Configure the default for all requests: -client = Ctxos( +ctxos = Ctxos( # default is 2 max_retries=0, ) # Or, configure per-request: -client.with_options(max_retries=5).complete.create( +ctxos.with_options(max_retries=5).completions.create( + prompt=f"{HUMAN_PROMPT} Can you help me effectively ask for a raise at work? {AI_PROMPT}", + max_tokens_to_sample=300, model="ctxos-1", - prompt="prompt", ) ``` ### Timeouts -By default requests time out after 1 minute. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: +Requests time out after 60 seconds by default. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration): ```python -from ctxos import Ctxos +from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT # Configure the default for all requests: -client = Ctxos( - # 20 seconds (default is 1 minute) +ctxos = Ctxos( + # default is 60s timeout=20.0, ) # More granular control: -client = Ctxos( +ctxos = Ctxos( timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), ) # Override per-request: -client.with_options(timeout=5.0).complete.create( +ctxos.with_options(timeout=5 * 1000).completions.create( + prompt=f"{HUMAN_PROMPT} Where can I get a good coffee in my neighbourhood? {AI_PROMPT}", + max_tokens_to_sample=300, model="ctxos-1", - prompt="prompt", ) ``` On timeout, an `APITimeoutError` is thrown. -Note that requests that time out are [retried twice by default](#retries). - -## Advanced - -### Logging - -We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. - -You can enable logging by setting the environment variable `CTXOS_LOG` to `info`. - -```shell -$ export CTXOS_LOG=info -``` - -Or to `debug` for more verbose logging. - -### How to tell whether `None` means `null` or missing - -In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: +Note that requests which time out will be [retried twice by default](#retries). -```py -if response.my_field is None: - if 'my_field' not in response.model_fields_set: - print('Got json like {}, without a "my_field" key present at all.') - else: - print('Got json like {"my_field": null}.') -``` - -### Accessing raw response data (e.g. headers) +## Default Headers -The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., +If you need to, you can override it by setting default headers per-request or on the client object. -```py -from ctxos import Ctxos - -client = Ctxos() -response = client.complete.with_raw_response.create( - model="ctxos-1", - prompt="prompt", -) -print(response.headers.get('X-My-Header')) - -complete = response.parse() # get the object that `complete.create()` would have returned -print(complete.id) -``` - -These methods return an [`APIResponse`](https://github.com/CtxOS/ctxos-sdk-python/tree/main/src/ctxos/_response.py) object. - -The async client returns an [`AsyncAPIResponse`](https://github.com/CtxOS/ctxos-sdk-python/tree/main/src/ctxos/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. - -#### `.with_streaming_response` - -The above interface eagerly reads the full response body when you make the request, which may not always be what you want. - -To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. +Be aware that doing so may result in incorrect types and other unexpected or undefined behavior in the SDK. ```python -with client.complete.with_streaming_response.create( - model="ctxos-1", - prompt="prompt", -) as response: - print(response.headers.get("X-My-Header")) - - for line in response.iter_lines(): - print(line) -``` - -The context manager is required so that the response will reliably be closed. - -### Making custom/undocumented requests - -This library is typed for convenient access to the documented API. - -If you need to access undocumented endpoints, params, or response properties, the library can still be used. - -#### Undocumented endpoints - -To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other -http verbs. Options on the client will be respected (such as retries) when making this request. - -```py -import httpx +from ctxos import Ctxos -response = client.post( - "/foo", - cast_to=httpx.Response, - body={"my_param": True}, +ctxos = Ctxos( + default_headers={"ctxos-version": "My-Custom-Value"}, ) - -print(response.headers.get("x-foo")) ``` -#### Undocumented request params - -If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request -options. - -#### Undocumented response properties - -To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You -can also get all the extra fields on the Pydantic model as a dict with -[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). - -### Configuring the HTTP client +## Advanced: Configuring custom URLs, proxies, and transports -You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: - -- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) -- Custom [transports](https://www.python-httpx.org/advanced/transports/) -- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality +You can configure the following keyword arguments when instantiating the client: ```python import httpx -from ctxos import Ctxos, DefaultHttpxClient +from ctxos import Ctxos -client = Ctxos( - # Or use the `CTXOS_BASE_URL` env var +ctxos = Ctxos( + # Use a custom base URL base_url="http://my.test.server.example.com:8083", - http_client=DefaultHttpxClient( - proxy="http://my.test.proxy.example.com", - transport=httpx.HTTPTransport(local_address="0.0.0.0"), - ), + proxies="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), ) ``` -You can also customize the client on a per-request basis by using `with_options()`: - -```python -client.with_options(http_client=DefaultHttpxClient(...)) -``` - -### Managing HTTP resources +See the httpx documentation for information about the [`proxies`](https://www.python-httpx.org/advanced/#http-proxying) and [`transport`](https://www.python-httpx.org/advanced/#custom-transports) keyword arguments. -By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. - -```py -from ctxos import Ctxos - -with Ctxos() as client: - # make requests here - ... - -# HTTP client is now closed -``` +## Status -## Versioning +This package is in beta. Its internals and interfaces are not stable and subject to change without a major semver bump; +please reach out if you rely on any undocumented behavior. -This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: - -1. Changes that only affect static types, without breaking runtime behavior. -2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ -3. Changes that we do not expect to impact the vast majority of users in practice. - -We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. - -We are keen for your feedback; please open an [issue](https://www.github.com/CtxOS/ctxos-sdk-python/issues) with questions, bugs, or suggestions. - -### Determining the installed version - -If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. - -You can determine the version that is being used at runtime with: - -```py -import ctxos -print(ctxos.__version__) -``` +We are keen for your feedback; please open an [issue](https://www.github.com/ctxos/ctxos-sdk-python/issues) with questions, bugs, or suggestions. ## Requirements -Python 3.9 or higher. - -## Contributing - -See [the contributing documentation](./CONTRIBUTING.md). +Python 3.7 or higher. diff --git a/bin/check-test-server b/bin/check-test-server new file mode 100755 index 0000000..34efa9d --- /dev/null +++ b/bin/check-test-server @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +function prism_is_running() { + curl --silent "http://localhost:4010" >/dev/null 2>&1 +} + +function is_overriding_api_base_url() { + [ -n "$API_BASE_URL" ] +} + +if is_overriding_api_base_url ; then + # If someone is running the tests against the live API, we can trust they know + # what they're doing and exit early. + echo -e "${GREEN}✔ Running tests against ${API_BASE_URL}${NC}" + + exit 0 +elif prism_is_running ; then + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo + + exit 0 +else + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" + echo -e "running against your OpenAPI spec." + echo + echo -e "${YELLOW}To fix:${NC}" + echo + echo -e "1. Install Prism (requires Node 16+):" + echo + echo -e " With npm:" + echo -e " \$ ${YELLOW}npm install -g @stoplight/prism-cli${NC}" + echo + echo -e " With yarn:" + echo -e " \$ ${YELLOW}yarn global add @stoplight/prism-cli${NC}" + echo + echo -e "2. Run the mock server" + echo + echo -e " To run the server, pass in the path of your OpenAPI" + echo -e " spec to the prism command:" + echo + echo -e " \$ ${YELLOW}prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +fi diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..ac28445 --- /dev/null +++ b/bin/test @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +bin/check-test-server && poetry run pytest "$@" diff --git a/examples/.keep b/examples/.keep deleted file mode 100644 index d8c73e9..0000000 --- a/examples/.keep +++ /dev/null @@ -1,4 +0,0 @@ -File generated from our OpenAPI spec by Stainless. - -This directory can be used to store example files demonstrating usage of this SDK. -It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/examples/demo_async.py b/examples/demo_async.py new file mode 100644 index 0000000..69e3a96 --- /dev/null +++ b/examples/demo_async.py @@ -0,0 +1,20 @@ +#!/usr/bin/env poetry run python + +import asyncio + +import ctxos +from ctxos import AsyncCtxos + + +async def main() -> None: + client = AsyncCtxos() + + res = await client.completions.create( + model="ctxos-1", + prompt=f"{ctxos.HUMAN_PROMPT} how does a court case get to the Supreme Court? {ctxos.AI_PROMPT}", + max_tokens_to_sample=1000, + ) + print(res.completion) + + +asyncio.run(main()) diff --git a/examples/demo_sync.py b/examples/demo_sync.py new file mode 100644 index 0000000..4d14ec9 --- /dev/null +++ b/examples/demo_sync.py @@ -0,0 +1,18 @@ +#!/usr/bin/env poetry run python + +import ctxos +from ctxos import Ctxos + + +def main() -> None: + client = Ctxos() + + res = client.completions.create( + model="ctxos-1", + prompt=f"{ctxos.HUMAN_PROMPT} how does a court case get to the Supreme Court? {ctxos.AI_PROMPT}", + max_tokens_to_sample=1000, + ) + print(res.completion) + + +main() diff --git a/examples/streaming.py b/examples/streaming.py new file mode 100644 index 0000000..2efdd6c --- /dev/null +++ b/examples/streaming.py @@ -0,0 +1,57 @@ +#!/usr/bin/env poetry run python + +import asyncio + +from ctxos import AI_PROMPT, HUMAN_PROMPT, Ctxos, APIStatusError, AsyncCtxos + +client = Ctxos() +async_client = AsyncCtxos() + +question = """ +Hey Ctxos! How can I recursively list all files in a directory in Python? +""" + + +def sync_stream() -> None: + stream = client.completions.create( + prompt=f"{HUMAN_PROMPT} {question}{AI_PROMPT}", + model="ctxos-1", + stream=True, + max_tokens_to_sample=300, + ) + + for completion in stream: + print(completion.completion, end="") + + print() + + +async def async_stream() -> None: + stream = await async_client.completions.create( + prompt=f"{HUMAN_PROMPT} {question}{AI_PROMPT}", + model="ctxos-1", + stream=True, + max_tokens_to_sample=300, + ) + + async for completion in stream: + print(completion.completion, end="") + + print() + + +def stream_error() -> None: + try: + client.completions.create( + prompt=f"{HUMAN_PROMPT}{question}{AI_PROMPT}", + model="Ctxos-unknown-model", + stream=True, + max_tokens_to_sample=300, + ) + except APIStatusError as err: + print(f"Caught API status error with response body: {err.response.text}") + + +sync_stream() +asyncio.run(async_stream()) +stream_error() diff --git a/examples/tokens.py b/examples/tokens.py new file mode 100644 index 0000000..b518c35 --- /dev/null +++ b/examples/tokens.py @@ -0,0 +1,32 @@ +#!/usr/bin/env poetry run python + +import asyncio + +from ctxos import Ctxos, AsyncCtxos + + +def sync_tokens() -> None: + client = Ctxos() + + text = "hello world!" + + tokens = client.count_tokens(text) + print(f"'{text}' is {tokens} tokens") + + assert tokens == 3 + + +async def async_tokens() -> None: + ctxos = AsyncCtxos() + + text = "fist message" + tokens = await ctxos.count_tokens(text) + print(f"'{text}' is {tokens} tokens") + + text = "second message" + tokens = await ctxos.count_tokens(text) + print(f"'{text}' is {tokens} tokens") + + +sync_tokens() +asyncio.run(async_tokens()) diff --git a/src/ctxos/_base_exceptions.py b/src/ctxos/_base_exceptions.py new file mode 100644 index 0000000..aac0010 --- /dev/null +++ b/src/ctxos/_base_exceptions.py @@ -0,0 +1,117 @@ +from typing_extensions import Literal + +from httpx import Request, Response + + +class APIError(Exception): + message: str + request: Request + + def __init__(self, message: str, request: Request) -> None: + super().__init__(message) + self.request = request + self.message = message + + +class APIResponseValidationError(APIError): + response: Response + status_code: int + + def __init__(self, request: Request, response: Response) -> None: + super().__init__("Data returned by API invalid for expected schema.", request) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: Response + status_code: int + + body: object + """The API response body. + + If the API responded with a valid JSON structure then this property will be the decoded result. + If it isn't a valid JSON structure then this will be the raw response. + """ + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request) + self.response = response + self.status_code = response.status_code + self.body = body + + +class BadRequestError(APIStatusError): + status_code: Literal[400] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 400 + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 401 + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 403 + + +class NotFoundError(APIStatusError): + status_code: Literal[404] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 404 + + +class ConflictError(APIStatusError): + status_code: Literal[409] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 409 + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 422 + + +class RateLimitError(APIStatusError): + status_code: Literal[429] + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = 429 + + +class InternalServerError(APIStatusError): + status_code: int + + def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: + super().__init__(message, request=request, response=response, body=body) + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, request: Request, message: str = "Connection error.") -> None: + super().__init__(message, request) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: Request) -> None: + super().__init__(request, "Request timed out.") diff --git a/src/ctxos/_tokenizers.py b/src/ctxos/_tokenizers.py new file mode 100644 index 0000000..c2ac920 --- /dev/null +++ b/src/ctxos/_tokenizers.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import cast +from pathlib import Path + +from anyio import Path as AsyncPath + +# tokenizers is untyped, https://github.com/huggingface/tokenizers/issues/811 +# note: this comment affects the entire file +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +from tokenizers import Tokenizer # type: ignore[import] + + +def _get_tokenizer_cache_path() -> Path: + return Path(__file__).parent / "tokenizer.json" + + +_tokenizer: Tokenizer | None = None + + +def _load_tokenizer(raw: str) -> Tokenizer: + global _tokenizer + + _tokenizer = cast(Tokenizer, Tokenizer.from_str(raw)) + return _tokenizer + + +def sync_get_tokenizer() -> Tokenizer: + if _tokenizer is not None: + return _tokenizer + + tokenizer_path = _get_tokenizer_cache_path() + text = tokenizer_path.read_text(encoding="utf-8") + return _load_tokenizer(text) + + +async def async_get_tokenizer() -> Tokenizer: + if _tokenizer is not None: + return _tokenizer + + tokenizer_path = AsyncPath(_get_tokenizer_cache_path()) + text = await tokenizer_path.read_text(encoding="utf-8") + return _load_tokenizer(text) diff --git a/src/ctxos/pagination.py b/src/ctxos/pagination.py new file mode 100644 index 0000000..2b7c4e7 --- /dev/null +++ b/src/ctxos/pagination.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. + +from typing import TypeVar + +from ._models import BaseModel + +_BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) diff --git a/uv.lock b/uv.lock index b0dc32f..0d27777 100644 --- a/uv.lock +++ b/uv.lock @@ -244,7 +244,7 @@ wheels = [ [[package]] name = "ctxos" -version = "0.0.1" +version = "0.0.3" source = { editable = "." } dependencies = [ { name = "anyio" }, From 8310917ebf0a68276b66bddff9bab4087e15e93d Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:16:57 +0000 Subject: [PATCH 05/10] init commit --- bin/check-release-environment | 0 examples/demo_async.py | 9 +++---- examples/demo_sync.py | 9 +++---- examples/streaming.py | 47 ++++++++++++++--------------------- examples/tokens.py | 16 ++++++------ src/ctxos/_base_exceptions.py | 18 -------------- src/ctxos/_tokenizers.py | 10 ++++---- 7 files changed, 38 insertions(+), 71 deletions(-) mode change 100644 => 100755 bin/check-release-environment diff --git a/bin/check-release-environment b/bin/check-release-environment old mode 100644 new mode 100755 diff --git a/examples/demo_async.py b/examples/demo_async.py index 69e3a96..c7e7040 100644 --- a/examples/demo_async.py +++ b/examples/demo_async.py @@ -2,19 +2,18 @@ import asyncio -import ctxos from ctxos import AsyncCtxos async def main() -> None: client = AsyncCtxos() - res = await client.completions.create( + res = await client.complete.create( model="ctxos-1", - prompt=f"{ctxos.HUMAN_PROMPT} how does a court case get to the Supreme Court? {ctxos.AI_PROMPT}", - max_tokens_to_sample=1000, + prompt="how does a court case get to the Supreme Court?", + max_tokens=1000, ) - print(res.completion) + print(res.choices[0].text) # type: ignore[index] asyncio.run(main()) diff --git a/examples/demo_sync.py b/examples/demo_sync.py index 4d14ec9..2a846ca 100644 --- a/examples/demo_sync.py +++ b/examples/demo_sync.py @@ -1,18 +1,17 @@ #!/usr/bin/env poetry run python -import ctxos from ctxos import Ctxos def main() -> None: client = Ctxos() - res = client.completions.create( + res = client.complete.create( model="ctxos-1", - prompt=f"{ctxos.HUMAN_PROMPT} how does a court case get to the Supreme Court? {ctxos.AI_PROMPT}", - max_tokens_to_sample=1000, + prompt="how does a court case get to the Supreme Court?", + max_tokens=1000, ) - print(res.completion) + print(res.choices[0].text) # type: ignore[index] main() diff --git a/examples/streaming.py b/examples/streaming.py index 2efdd6c..1eaee55 100644 --- a/examples/streaming.py +++ b/examples/streaming.py @@ -2,7 +2,7 @@ import asyncio -from ctxos import AI_PROMPT, HUMAN_PROMPT, Ctxos, APIStatusError, AsyncCtxos +from ctxos import Ctxos, AsyncCtxos, APIStatusError client = Ctxos() async_client = AsyncCtxos() @@ -12,46 +12,35 @@ """ -def sync_stream() -> None: - stream = client.completions.create( - prompt=f"{HUMAN_PROMPT} {question}{AI_PROMPT}", +def sync_request() -> None: + response = client.complete.create( + prompt=question, model="ctxos-1", - stream=True, - max_tokens_to_sample=300, + max_tokens=300, ) + print(response.choices[0].text) # type: ignore[index] - for completion in stream: - print(completion.completion, end="") - print() - - -async def async_stream() -> None: - stream = await async_client.completions.create( - prompt=f"{HUMAN_PROMPT} {question}{AI_PROMPT}", +async def async_request() -> None: + response = await async_client.complete.create( + prompt=question, model="ctxos-1", - stream=True, - max_tokens_to_sample=300, + max_tokens=300, ) - - async for completion in stream: - print(completion.completion, end="") - - print() + print(response.choices[0].text) # type: ignore[index] -def stream_error() -> None: +def request_error() -> None: try: - client.completions.create( - prompt=f"{HUMAN_PROMPT}{question}{AI_PROMPT}", + client.complete.create( + prompt=question, model="Ctxos-unknown-model", - stream=True, - max_tokens_to_sample=300, + max_tokens=300, ) except APIStatusError as err: print(f"Caught API status error with response body: {err.response.text}") -sync_stream() -asyncio.run(async_stream()) -stream_error() +sync_request() +asyncio.run(async_request()) +request_error() diff --git a/examples/tokens.py b/examples/tokens.py index b518c35..04bf6cb 100644 --- a/examples/tokens.py +++ b/examples/tokens.py @@ -10,22 +10,20 @@ def sync_tokens() -> None: text = "hello world!" - tokens = client.count_tokens(text) - print(f"'{text}' is {tokens} tokens") - - assert tokens == 3 + tokens = client.tokens.count(input=text) + print(f"'{text}' is {tokens.tokens} tokens") async def async_tokens() -> None: ctxos = AsyncCtxos() - text = "fist message" - tokens = await ctxos.count_tokens(text) - print(f"'{text}' is {tokens} tokens") + text = "first message" + tokens = await ctxos.tokens.count(input=text) + print(f"'{text}' is {tokens.tokens} tokens") text = "second message" - tokens = await ctxos.count_tokens(text) - print(f"'{text}' is {tokens} tokens") + tokens = await ctxos.tokens.count(input=text) + print(f"'{text}' is {tokens.tokens} tokens") sync_tokens() diff --git a/src/ctxos/_base_exceptions.py b/src/ctxos/_base_exceptions.py index aac0010..607bca1 100644 --- a/src/ctxos/_base_exceptions.py +++ b/src/ctxos/_base_exceptions.py @@ -1,5 +1,3 @@ -from typing_extensions import Literal - from httpx import Request, Response @@ -44,64 +42,48 @@ def __init__(self, message: str, *, request: Request, response: Response, body: class BadRequestError(APIStatusError): - status_code: Literal[400] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 400 class AuthenticationError(APIStatusError): - status_code: Literal[401] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 401 class PermissionDeniedError(APIStatusError): - status_code: Literal[403] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 403 class NotFoundError(APIStatusError): - status_code: Literal[404] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 404 class ConflictError(APIStatusError): - status_code: Literal[409] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 409 class UnprocessableEntityError(APIStatusError): - status_code: Literal[422] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 422 class RateLimitError(APIStatusError): - status_code: Literal[429] - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = 429 class InternalServerError(APIStatusError): - status_code: int - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: super().__init__(message, request=request, response=response, body=body) self.status_code = response.status_code diff --git a/src/ctxos/_tokenizers.py b/src/ctxos/_tokenizers.py index c2ac920..52942ce 100644 --- a/src/ctxos/_tokenizers.py +++ b/src/ctxos/_tokenizers.py @@ -7,8 +7,8 @@ # tokenizers is untyped, https://github.com/huggingface/tokenizers/issues/811 # note: this comment affects the entire file -# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false -from tokenizers import Tokenizer # type: ignore[import] +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportReturnType=false +from tokenizers import Tokenizer # type: ignore[import, reportMissingTypeStubs] def _get_tokenizer_cache_path() -> Path: @@ -18,14 +18,14 @@ def _get_tokenizer_cache_path() -> Path: _tokenizer: Tokenizer | None = None -def _load_tokenizer(raw: str) -> Tokenizer: +def _load_tokenizer(raw: str) -> "Tokenizer": # type: ignore[return-value] global _tokenizer _tokenizer = cast(Tokenizer, Tokenizer.from_str(raw)) return _tokenizer -def sync_get_tokenizer() -> Tokenizer: +def sync_get_tokenizer() -> "Tokenizer": # type: ignore[return-value] if _tokenizer is not None: return _tokenizer @@ -34,7 +34,7 @@ def sync_get_tokenizer() -> Tokenizer: return _load_tokenizer(text) -async def async_get_tokenizer() -> Tokenizer: +async def async_get_tokenizer() -> "Tokenizer": # type: ignore[return-value] if _tokenizer is not None: return _tokenizer From d11fc855234c503f06df4582d015debb5a62d1bb Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:41:32 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8=20Add=20tools/function=20callin?= =?UTF-8?q?g=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Tool, Function, ToolCall, ToolChoice types - Add function_tool() helper to convert Python functions to tool definitions - Update CompleteCreateParams to support tools and tool_choice parameters - Update CompleteCreateResponse to support tool_calls in choices - Remove unused _base_exceptions.py file - Update README with tools documentation --- README.md | 183 ++++++++++++++++---- src/ctxos/_base_exceptions.py | 99 ----------- src/ctxos/resources/complete.py | 19 ++ src/ctxos/types/__init__.py | 8 + src/ctxos/types/complete_create_params.py | 7 + src/ctxos/types/complete_create_response.py | 3 + src/ctxos/types/tool.py | 167 ++++++++++++++++++ 7 files changed, 351 insertions(+), 135 deletions(-) delete mode 100644 src/ctxos/_base_exceptions.py create mode 100644 src/ctxos/types/tool.py diff --git a/README.md b/README.md index 0576873..b0e2a4a 100644 --- a/README.md +++ b/README.md @@ -76,19 +76,18 @@ pip install ctxos ## Usage ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +from ctxos import Ctxos -ctxos = Ctxos( +client = Ctxos( # defaults to os.environ.get("CTXOS_API_KEY") api_key="my api key", ) -completion = ctxos.completions.create( +completion = client.complete.create( model="ctxos-1", - max_tokens_to_sample=300, - prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court? {AI_PROMPT}", + prompt="how does a court case get to the Supreme Court?", ) -print(completion.completion) +print(completion.choices[0].text) ``` While you can provide an `api_key` keyword argument, we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) @@ -99,21 +98,20 @@ and adding `CTXOS_API_KEY="my api key"` to your `.env` file so that your API Key Simply import `AsyncCtxos` instead of `Ctxos` and use `await` with each API call: ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +from ctxos import AsyncCtxos -ctxos = AsyncCtxos( +client = AsyncCtxos( # defaults to os.environ.get("CTXOS_API_KEY") api_key="my api key", ) async def main(): - completion = await ctxos.completions.create( + completion = await client.complete.create( model="ctxos-1", - max_tokens_to_sample=300, - prompt=f"{HUMAN_PROMPT} how does a court case get to the Supreme Court? {AI_PROMPT}", + prompt="how does a court case get to the Supreme Court?", ) - print(completion.completion) + print(completion.choices[0].text) asyncio.run(main()) @@ -126,24 +124,39 @@ Functionality between the synchronous and asynchronous clients is otherwise iden We provide support for streaming responses using Server Side Events (SSE). ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +from ctxos import Ctxos -ctxos = Ctxos() +client = Ctxos() -stream = ctxos.completions.create( - prompt=f"{HUMAN_PROMPT} Your prompt here {AI_PROMPT}", - max_tokens_to_sample=300, +stream = client.complete.create( + prompt="Your prompt here", model="ctxos-1", stream=True, ) for completion in stream: - print(completion.completion) + print(completion.choices[0].text) ``` The async client uses the exact same interface. ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +from ctxos import AsyncCtxos + +client = AsyncCtxos() + +stream = await client.complete.create( + prompt="Your prompt here", + model="ctxos-1", + stream=True, +) +async for completion in stream: + print(completion.choices[0].text) +``` + +The async client uses the exact same interface. + +```python +from ctxos import AsyncCtxos ctxos = AsyncCtxos() @@ -157,6 +170,106 @@ async for completion in stream: print(completion.completion) ``` +## Tools / Function Calling + +The SDK supports tools (also known as function calling), allowing the model to call your Python functions and return structured data. + +### Defining Tools + +Use the `function_tool` helper to convert Python functions into tool definitions: + +```python +from ctxos import Ctxos +from ctxos.types import function_tool + +def get_weather(location: str, unit: str = "celsius") -> str: + """Get the weather for a location.""" + return f"Weather in {location}: 72 degrees {unit}" + +def get_stock_price(symbol: str) -> float: + """Get the current stock price for a symbol.""" + return 150.25 + +client = Ctxos() + +# Create tool definitions +tools = [ + function_tool(get_weather), + function_tool(get_stock_price), +] +``` + +### Calling with Tools + +```python +response = client.completions.create( + model="ctxos-1", + prompt="What's the weather in San Francisco and what's AAPL's stock price?", + tools=tools, +) + +# Process tool calls from the response +for choice in response.choices or []: + if choice.tool_calls: + for tool_call in choice.tool_calls: + print(f"Called: {tool_call.function.name}") + print(f"Arguments: {tool_call.function.arguments}") +``` + +### Tool Choice + +Control which tool the model calls using `tool_choice`: + +```python +# Allow the model to decide (default) +response = client.completions.create( + model="ctxos-1", + prompt="What's the weather?", + tools=tools, + tool_choice="auto", +) + +# Force a specific tool +response = client.completions.create( + model="ctxos-1", + prompt="What's the weather?", + tools=tools, + tool_choice={"type": "function", "function": {"name": "get_weather"}}, +) + +# Disable tool calling +response = client.completions.create( + model="ctxos-1", + prompt="Hello!", + tools=tools, + tool_choice="none", +) +``` + +### Manual Tool Definition + +You can also define tools manually using dictionaries: + +```python +tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"}, + "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}, + }, + "required": ["location"], + }, + } + } +] +``` + ## Using Types Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict), while responses are [Pydantic](https://pydantic-docs.helpmanual.io/) models. This helps provide autocomplete and documentation within your editor. @@ -173,14 +286,13 @@ response), a subclass of `ctxos.APIStatusError` will be raised, containing `stat All errors inherit from `ctxos.APIError`. ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +from ctxos import Ctxos -ctxos = Ctxos() +client = Ctxos() try: - ctxos.completions.create( - prompt=f"{HUMAN_PROMPT} Your prompt here {AI_PROMPT}", - max_tokens_to_sample=300, + client.complete.create( + prompt="Your prompt here", model="ctxos-1", ) except ctxos.APIConnectionError as e: @@ -216,18 +328,17 @@ and >=500 Internal errors will all be retried by default. You can use the `max_retries` option to configure or disable this: ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +from ctxos import Ctxos # Configure the default for all requests: -ctxos = Ctxos( +client = Ctxos( # default is 2 max_retries=0, ) # Or, configure per-request: -ctxos.with_options(max_retries=5).completions.create( - prompt=f"{HUMAN_PROMPT} Can you help me effectively ask for a raise at work? {AI_PROMPT}", - max_tokens_to_sample=300, +client.with_options(max_retries=5).complete.create( + prompt="Can you help me effectively ask for a raise at work?", model="ctxos-1", ) ``` @@ -238,23 +349,23 @@ Requests time out after 60 seconds by default. You can configure this with a `ti which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration): ```python -from ctxos import Ctxos, HUMAN_PROMPT, AI_PROMPT +import httpx +from ctxos import Ctxos # Configure the default for all requests: -ctxos = Ctxos( +client = Ctxos( # default is 60s timeout=20.0, ) # More granular control: -ctxos = Ctxos( +client = Ctxos( timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), ) # Override per-request: -ctxos.with_options(timeout=5 * 1000).completions.create( - prompt=f"{HUMAN_PROMPT} Where can I get a good coffee in my neighbourhood? {AI_PROMPT}", - max_tokens_to_sample=300, +client.with_options(timeout=5 * 1000).complete.create( + prompt="Where can I get a good coffee in my neighbourhood?", model="ctxos-1", ) ``` @@ -304,4 +415,4 @@ We are keen for your feedback; please open an [issue](https://www.github.com/ctx ## Requirements -Python 3.7 or higher. +Python 3.9 or higher. diff --git a/src/ctxos/_base_exceptions.py b/src/ctxos/_base_exceptions.py deleted file mode 100644 index 607bca1..0000000 --- a/src/ctxos/_base_exceptions.py +++ /dev/null @@ -1,99 +0,0 @@ -from httpx import Request, Response - - -class APIError(Exception): - message: str - request: Request - - def __init__(self, message: str, request: Request) -> None: - super().__init__(message) - self.request = request - self.message = message - - -class APIResponseValidationError(APIError): - response: Response - status_code: int - - def __init__(self, request: Request, response: Response) -> None: - super().__init__("Data returned by API invalid for expected schema.", request) - self.response = response - self.status_code = response.status_code - - -class APIStatusError(APIError): - """Raised when an API response has a status code of 4xx or 5xx.""" - - response: Response - status_code: int - - body: object - """The API response body. - - If the API responded with a valid JSON structure then this property will be the decoded result. - If it isn't a valid JSON structure then this will be the raw response. - """ - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request) - self.response = response - self.status_code = response.status_code - self.body = body - - -class BadRequestError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 400 - - -class AuthenticationError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 401 - - -class PermissionDeniedError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 403 - - -class NotFoundError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 404 - - -class ConflictError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 409 - - -class UnprocessableEntityError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 422 - - -class RateLimitError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 429 - - -class InternalServerError(APIStatusError): - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = response.status_code - - -class APIConnectionError(APIError): - def __init__(self, request: Request, message: str = "Connection error.") -> None: - super().__init__(message, request) - - -class APITimeoutError(APIConnectionError): - def __init__(self, request: Request) -> None: - super().__init__(request, "Request timed out.") diff --git a/src/ctxos/resources/complete.py b/src/ctxos/resources/complete.py index d9d5182..7f02408 100644 --- a/src/ctxos/resources/complete.py +++ b/src/ctxos/resources/complete.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import List, Union + import httpx from ..types import complete_create_params @@ -15,6 +17,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) +from ..types.tool import Tool, ToolChoice from .._base_client import make_request_options from ..types.complete_create_response import CompleteCreateResponse @@ -48,6 +51,8 @@ def create( prompt: str, max_tokens: int | Omit = omit, temperature: float | Omit = omit, + tools: List[Tool] | Omit = omit, + tool_choice: Union[str, ToolChoice] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -59,6 +64,10 @@ def create( Create completion Args: + tools: A list of tools the model may call. The model will determine which (if any) function to call. + + tool_choice: Controls which (if any) function is called by the model. Can be "none", "auto", or a specific function name. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -75,6 +84,8 @@ def create( "prompt": prompt, "max_tokens": max_tokens, "temperature": temperature, + "tools": tools, + "tool_choice": tool_choice, }, complete_create_params.CompleteCreateParams, ), @@ -112,6 +123,8 @@ async def create( prompt: str, max_tokens: int | Omit = omit, temperature: float | Omit = omit, + tools: List[Tool] | Omit = omit, + tool_choice: Union[str, ToolChoice] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -123,6 +136,10 @@ async def create( Create completion Args: + tools: A list of tools the model may call. The model will determine which (if any) function to call. + + tool_choice: Controls which (if any) function is called by the model. Can be "none", "auto", or a specific function name. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -139,6 +156,8 @@ async def create( "prompt": prompt, "max_tokens": max_tokens, "temperature": temperature, + "tools": tools, + "tool_choice": tool_choice, }, complete_create_params.CompleteCreateParams, ), diff --git a/src/ctxos/types/__init__.py b/src/ctxos/types/__init__.py index 70b9974..6448b7e 100644 --- a/src/ctxos/types/__init__.py +++ b/src/ctxos/types/__init__.py @@ -2,6 +2,14 @@ from __future__ import annotations +from .tool import ( + Tool as Tool, + Function as Function, + ToolCall as ToolCall, + ToolChoice as ToolChoice, + FunctionTool as FunctionTool, + function_tool as function_tool, +) from .token_count_params import TokenCountParams as TokenCountParams from .token_count_response import TokenCountResponse as TokenCountResponse from .complete_create_params import CompleteCreateParams as CompleteCreateParams diff --git a/src/ctxos/types/complete_create_params.py b/src/ctxos/types/complete_create_params.py index e79ef89..ef678c7 100644 --- a/src/ctxos/types/complete_create_params.py +++ b/src/ctxos/types/complete_create_params.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import List, Union from typing_extensions import Required, TypedDict +from .tool import Tool, ToolChoice + __all__ = ["CompleteCreateParams"] @@ -15,3 +18,7 @@ class CompleteCreateParams(TypedDict, total=False): max_tokens: int temperature: float + + tools: List[Tool] + + tool_choice: Union[str, ToolChoice] diff --git a/src/ctxos/types/complete_create_response.py b/src/ctxos/types/complete_create_response.py index a1843fd..79df772 100644 --- a/src/ctxos/types/complete_create_response.py +++ b/src/ctxos/types/complete_create_response.py @@ -2,6 +2,7 @@ from typing import List, Optional +from .tool import ToolCall from .._models import BaseModel __all__ = ["CompleteCreateResponse", "Choice", "Usage"] @@ -14,6 +15,8 @@ class Choice(BaseModel): text: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + class Usage(BaseModel): completion_tokens: Optional[int] = None diff --git a/src/ctxos/types/tool.py b/src/ctxos/types/tool.py new file mode 100644 index 0000000..074672d --- /dev/null +++ b/src/ctxos/types/tool.py @@ -0,0 +1,167 @@ +from typing import Any, Dict, List, Type, Union, Callable, Optional, get_args, get_origin, get_type_hints + +from .._models import BaseModel + +__all__ = [ + "Tool", + "Function", + "ToolCall", + "ToolChoice", + "FunctionTool", + "function_tool", +] + + +class FunctionTool(BaseModel): + type: str = "function" + function: "Function" + + +class Function(BaseModel): + name: str + description: str + parameters: Dict[str, Any] + + +class ToolCall(BaseModel): + id: str + type: str = "function" + function: "FunctionCall" + + +class FunctionCall(BaseModel): + name: str + arguments: str + + +class ToolChoice(BaseModel): + type: str = "function" + function: "ToolChoiceFunction" + + +class ToolChoiceFunction(BaseModel): + name: str + + +Tool = Union[FunctionTool, Dict[str, Any]] + + +def _get_json_type(py_type: Type[Any]) -> str: + """Map Python types to JSON schema types.""" + py_type = get_origin(py_type) or py_type + + if py_type is int or py_type is float: + return "number" + if py_type is str: + return "string" + if py_type is bool: + return "boolean" + if py_type is list: + return "array" + if py_type is dict or py_type is object: + return "object" + return "string" + + +def _python_type_to_json_schema(py_type: Any, description: str = "") -> Dict[str, Any]: + """Convert a Python type to JSON schema.""" + origin = get_origin(py_type) + + if origin is Union: + args = get_args(py_type) + non_none = [a for a in args if a is not type(None)] + if non_none: + result = _python_type_to_json_schema(non_none[0], description) + if type(None) in args: + result["nullable"] = True + return result + + if origin is list: + args = get_args(py_type) + if args: + return { + "type": "array", + "items": _python_type_to_json_schema(args[0]), + } + return {"type": "array", "items": {}} + + if origin is dict: + args = get_args(py_type) + if len(args) == 2: + return { + "type": "object", + "additionalProperties": _python_type_to_json_schema(args[1]), + } + return {"type": "object"} + + json_type = _get_json_type(py_type) + result = {"type": json_type} + if description: + result["description"] = description + return result + + +def _extract_parameters(func: Callable[..., Any]) -> Dict[str, Any]: + """Extract JSON schema parameters from a Python function's type hints.""" + try: + hints = get_type_hints(func) + except Exception: + hints = {} + + properties: Dict[str, Any] = {} + required: List[str] = [] + + for param_name, param_type in hints.items(): + if param_name in ("return", "self", "cls"): + continue + + import inspect + + sig = inspect.signature(func) + param = sig.parameters.get(param_name) + param_description = "" + if param and param.default is not inspect.Parameter.empty: + param_description = f"Default: {param.default}" + + properties[param_name] = _python_type_to_json_schema(param_type, param_description) + + if param is None or param.default is inspect.Parameter.empty: + required.append(param_name) + + return { + "type": "object", + "properties": properties, + "required": required, + } + + +def function_tool( + func: Callable[..., Any], + *, + name: Optional[str] = None, + description: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a tool definition from a Python function. + + Args: + func: The Python function to convert to a tool + name: Optional name for the tool (defaults to function name) + description: Optional description (defaults to function docstring) + + Returns: + A dictionary representing the tool that can be passed to the API + """ + tool_name = name or func.__name__ + tool_description = description or func.__doc__ or "" + + parameters = _extract_parameters(func) + + return { + "type": "function", + "function": { + "name": tool_name, + "description": tool_description.strip(), + "parameters": parameters, + }, + } From 4ed0468077146f1caa8560f4487b755d9c75afcc Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:51:42 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=A7=20Add=20BaseTool=20and=20Too?= =?UTF-8?q?lUser=20classes=20for=20structured=20tool=20calling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 56 ++++++++++ src/ctxos/__init__.py | 3 + src/ctxos/tools/__init__.py | 211 ++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 src/ctxos/tools/__init__.py diff --git a/README.md b/README.md index b0e2a4a..ba3518f 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,62 @@ tools = [ ] ``` +### BaseTool and ToolUser + +For a more structured approach to tool calling, you can use the `BaseTool` class and `ToolUser` helper. + +#### Defining a Tool with BaseTool + +```python +from ctxos.tools import BaseTool + +class GetWeatherTool(BaseTool): + def use_tool(self, location: str, unit: str = "celsius") -> str: + return f"Weather in {location}: 72 degrees {unit}" + +# Instantiate with name, description, and parameters +weather_tool = GetWeatherTool( + name="get_weather", + description="Get the weather for a location", + parameters=[ + {"name": "location", "type": "str", "description": "City name"}, + {"name": "unit", "type": "str", "description": "Temperature unit (celsius/fahrenheit)"}, + ] +) +``` + +#### Using ToolUser + +```python +from ctxos.tools import BaseTool, ToolUser + +# Create your tools +class GetWeatherTool(BaseTool): + def use_tool(self, location: str) -> str: + return f"Weather in {location}: 72 degrees" + +# Create a ToolUser with your tools +tool_user = ToolUser([GetWeatherTool( + name="get_weather", + description="Get the weather for a location", + parameters=[ + {"name": "location", "type": "str", "description": "City name"}, + ] +)]) + +# Manual mode - returns tool arguments for you to execute +messages = [{"role": "user", "content": "What's the weather in Los Angeles?"}] +result = tool_user.use_tools(messages, execution_mode="manual") +print(result) +# Returns tool_inputs message with tool_arguments for you to execute + +# Automatic mode - executes the tool automatically +messages = [{"role": "user", "content": "What's the weather in Los Angeles?"}] +result = tool_user.use_tools(messages, execution_mode="automatic") +print(result) +# Executes the tool and returns the final assistant response +``` + ## Using Types Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict), while responses are [Pydantic](https://pydantic-docs.helpmanual.io/) models. This helps provide autocomplete and documentation within your editor. diff --git a/src/ctxos/__init__.py b/src/ctxos/__init__.py index a154bde..1ba96e9 100644 --- a/src/ctxos/__init__.py +++ b/src/ctxos/__init__.py @@ -28,9 +28,12 @@ ) from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging +from .tools import BaseTool as BaseTool, ToolUser as ToolUser __all__ = [ "types", + "BaseTool", + "ToolUser", "__version__", "__title__", "NoneType", diff --git a/src/ctxos/tools/__init__.py b/src/ctxos/tools/__init__.py new file mode 100644 index 0000000..cb2b9df --- /dev/null +++ b/src/ctxos/tools/__init__.py @@ -0,0 +1,211 @@ +import json +from typing import Any, Dict, List, Optional + +from .. import Ctxos + +__all__ = ["BaseTool", "ToolUser"] + + +class BaseTool: + """ + Base class for defining tools that can be used with ctxos. + + To create a tool, inherit from this class and implement the use_tool() method. + + Example: + class GetWeatherTool(BaseTool): + def use_tool(self, location: str, unit: str = "celsius") -> str: + return f"Weather in {location}: 72 degrees {unit}" + + tool = GetWeatherTool( + name="get_weather", + description="Get the weather for a location", + parameters=[ + {"name": "location", "type": "str", "description": "City name"}, + {"name": "unit", "type": "str", "description": "Temperature unit"}, + ] + ) + """ + + def __init__( + self, + name: str, + description: str, + parameters: List[Dict[str, str]], + ): + self.name = name + self.description = description + self.parameters = parameters + + def use_tool(self, **kwargs: Any) -> Any: + """ + Implement this method to define what your tool does. + + Args: + **kwargs: The arguments passed to the tool + + Returns: + Any: The result of the tool execution + """ + raise NotImplementedError("Subclasses must implement use_tool()") + + def to_dict(self) -> Dict[str, Any]: + """Convert the tool to a dictionary for the API.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": { + param["name"]: { + "type": param["type"], + "description": param.get("description", ""), + } + for param in self.parameters + }, + "required": [param["name"] for param in self.parameters if param.get("required", False)], + }, + }, + } + + +class ToolUser: + """ + ToolUser allows you to use ctxos with a list of tools. + + Example: + tool = GetWeatherTool(...) + tool_user = ToolUser([tool]) + + # Manual mode - returns tool arguments for you to execute + messages = [{"role": "user", "content": "What's the weather in LA?"}] + result = tool_user.use_tools(messages, execution_mode="manual") + + # Automatic mode - executes the tool automatically + messages = [{"role": "user", "content": "What's the weather in LA?"}] + result = tool_user.use_tools(messages, execution_mode="automatic") + """ + + def __init__(self, tools: List[BaseTool]): + self.tools = tools + + def get_tool(self, name: str) -> Optional[BaseTool]: + """Get a tool by name.""" + return next((t for t in self.tools if t.name == name), None) + + def use_tools( + self, + messages: List[Dict[str, Any]], + execution_mode: str = "manual", + ) -> List[Dict[str, Any]]: + """ + Use tools with the given messages. + + Args: + messages: List of message dicts with keys: role, content, tool_inputs, tool_outputs, tool_error + execution_mode: "automatic" to execute tools, "manual" to return arguments + + Returns: + Updated messages list including tool_inputs/tool_outputs + """ + client = Ctxos() + tool_definitions = [tool.to_dict() for tool in self.tools] # type: ignore[list-item] + + response = client.complete.create( + model="ctxos-1", + prompt=messages[-1]["content"], + tools=tool_definitions, # type: ignore[arg-type] + ) + + choices = response.choices + if not choices: + return messages + + choice = choices[0] + if choice is None: + return messages + + tool_calls = choice.tool_calls + + if not tool_calls: + messages.append( + { + "role": "assistant", + "content": choice.text or "", + } + ) + return messages + + tool_inputs_message: Dict[str, Any] = { + "role": "tool_inputs", + "content": "", + "tool_inputs": [ + { + "tool_name": tc.function.name, + "tool_arguments": tc.function.arguments, + } + for tc in tool_calls + ], + } + + if execution_mode == "automatic": + tool_outputs: List[Dict[str, Any]] = [] + for tool_call in tool_calls: + tool = self.get_tool(tool_call.function.name) + if tool is None: + tool_outputs.append( + { + "tool_name": tool_call.function.name, + "output": None, + "error": f"No tool named {tool_call.function.name} available.", + } + ) + continue + + try: + args = json.loads(tool_call.function.arguments) + result = tool.use_tool(**args) + tool_outputs.append( + { + "tool_name": tool_call.function.name, + "output": str(result), + } + ) + except Exception as e: + tool_outputs.append( + { + "tool_name": tool_call.function.name, + "output": None, + "error": str(e), + } + ) + + messages.append(tool_inputs_message) + messages.append( + { + "role": "tool_outputs", + "content": "", + "tool_outputs": tool_outputs, + "tool_error": None, + } + ) + + second_response = client.complete.create( + model="ctxos-1", + prompt="Continue the conversation given the tool results.", + tools=tool_definitions, # type: ignore[arg-type] + ) + + second_choice = second_response.choices[0] + messages.append( + { + "role": "assistant", + "content": second_choice.text if second_choice and second_choice.text else "", + } + ) + else: + messages.append(tool_inputs_message) + + return messages From 5b5196a5b2d1842ecb238257349dc87d785533b9 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:54:13 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=93=9D=20Fix=20README=20API=20usage?= =?UTF-8?q?=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ba3518f..0d19a47 100644 --- a/README.md +++ b/README.md @@ -158,16 +158,15 @@ The async client uses the exact same interface. ```python from ctxos import AsyncCtxos -ctxos = AsyncCtxos() +client = AsyncCtxos() -stream = await ctxos.completions.create( - prompt=f"{HUMAN_PROMPT} Your prompt here {AI_PROMPT}", - max_tokens_to_sample=300, +stream = await client.complete.create( + prompt="Your prompt here", model="ctxos-1", stream=True, ) async for completion in stream: - print(completion.completion) + print(completion.choices[0].text) ``` ## Tools / Function Calling @@ -202,7 +201,7 @@ tools = [ ### Calling with Tools ```python -response = client.completions.create( +response = client.complete.create( model="ctxos-1", prompt="What's the weather in San Francisco and what's AAPL's stock price?", tools=tools, @@ -222,7 +221,7 @@ Control which tool the model calls using `tool_choice`: ```python # Allow the model to decide (default) -response = client.completions.create( +response = client.complete.create( model="ctxos-1", prompt="What's the weather?", tools=tools, @@ -230,7 +229,7 @@ response = client.completions.create( ) # Force a specific tool -response = client.completions.create( +response = client.complete.create( model="ctxos-1", prompt="What's the weather?", tools=tools, @@ -238,7 +237,7 @@ response = client.completions.create( ) # Disable tool calling -response = client.completions.create( +response = client.complete.create( model="ctxos-1", prompt="Hello!", tools=tools, From 4d0d2447be72214974ea9a1a0e4a8ebf48ebd015 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:56:31 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=94=A7=20Fix=20import=20sorting=20i?= =?UTF-8?q?n=20=5F=5Finit=5F=5F.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctxos/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctxos/__init__.py b/src/ctxos/__init__.py index 1ba96e9..92ecb6c 100644 --- a/src/ctxos/__init__.py +++ b/src/ctxos/__init__.py @@ -3,6 +3,7 @@ import typing as _t from . import types +from .tools import BaseTool as BaseTool, ToolUser as ToolUser from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import Ctxos, Client, Stream, Timeout, Transport, AsyncCtxos, AsyncClient, AsyncStream, RequestOptions @@ -28,7 +29,6 @@ ) from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging -from .tools import BaseTool as BaseTool, ToolUser as ToolUser __all__ = [ "types", From 3765ebaf6f1cba12df9a8663e54c5f4587bded3f Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:02:21 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=A7=20Fix=20circular=20import=20?= =?UTF-8?q?in=20tools=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctxos/tools/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ctxos/tools/__init__.py b/src/ctxos/tools/__init__.py index cb2b9df..98be353 100644 --- a/src/ctxos/tools/__init__.py +++ b/src/ctxos/tools/__init__.py @@ -1,8 +1,6 @@ import json from typing import Any, Dict, List, Optional -from .. import Ctxos - __all__ = ["BaseTool", "ToolUser"] @@ -110,6 +108,8 @@ def use_tools( Returns: Updated messages list including tool_inputs/tool_outputs """ + from .. import Ctxos + client = Ctxos() tool_definitions = [tool.to_dict() for tool in self.tools] # type: ignore[list-item] @@ -124,7 +124,7 @@ def use_tools( return messages choice = choices[0] - if choice is None: + if not choice: return messages tool_calls = choice.tool_calls @@ -198,11 +198,12 @@ def use_tools( tools=tool_definitions, # type: ignore[arg-type] ) - second_choice = second_response.choices[0] + second_choices = second_response.choices + second_text = second_choices[0].text if second_choices and second_choices[0].text else "" messages.append( { "role": "assistant", - "content": second_choice.text if second_choice and second_choice.text else "", + "content": second_text, } ) else: