diff --git a/README.md b/README.md index 2bb7d9a..0d19a47 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,117 @@ -# 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 client = Ctxos( - api_key=os.environ.get("CTXOS_API_KEY"), # This is the default and can be omitted + # defaults to os.environ.get("CTXOS_API_KEY") + api_key="my api key", ) -complete = client.complete.create( +completion = client.complete.create( model="ctxos-1", - prompt="prompt", + prompt="how does a court case get to the Supreme Court?", ) -print(complete.id) +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/) -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 client = AsyncCtxos( - api_key=os.environ.get("CTXOS_API_KEY"), # This is the default and can be omitted + # 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 client.complete.create( model="ctxos-1", - prompt="prompt", + prompt="how does a court case get to the Supreme Court?", ) - print(complete.id) + print(completion.choices[0].text) asyncio.run(main()) @@ -71,69 +119,236 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. -### With aiohttp +## Streaming Responses -By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. +We provide support for streaming responses using Server Side Events (SSE). -You can enable this by installing `aiohttp`: +```python +from ctxos import Ctxos -```sh -# install from PyPI -pip install ctxos[aiohttp] +client = Ctxos() + +stream = client.complete.create( + prompt="Your prompt here", + model="ctxos-1", + stream=True, +) +for completion in stream: + print(completion.choices[0].text) ``` -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 +client = 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) +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. -asyncio.run(main()) +```python +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) +``` + +## 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.complete.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.complete.create( + model="ctxos-1", + prompt="What's the weather?", + tools=tools, + tool_choice="auto", +) + +# Force a specific tool +response = client.complete.create( + model="ctxos-1", + prompt="What's the weather?", + tools=tools, + tool_choice={"type": "function", "function": {"name": "get_weather"}}, +) + +# Disable tool calling +response = client.complete.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"], + }, + } + } +] +``` + +### 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 types +#### Using ToolUser -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: +```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 -- 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 client = Ctxos() try: client.complete.create( + prompt="Your prompt here", model="ctxos-1", - prompt="prompt", ) except ctxos.APIConnectionError as e: print("The server could not be reached") @@ -146,7 +361,7 @@ except ctxos.APIStatusError as e: print(e.response) ``` -Error codes are as follows: +Error codes are as followed: | Status Code | Error Type | | ----------- | -------------------------- | @@ -161,11 +376,11 @@ 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 @@ -178,22 +393,23 @@ client = Ctxos( # Or, configure per-request: client.with_options(max_retries=5).complete.create( + prompt="Can you help me effectively ask for a raise at work?", 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 +import httpx from ctxos import Ctxos # Configure the default for all requests: client = Ctxos( - # 20 seconds (default is 1 minute) + # default is 60s timeout=20.0, ) @@ -203,186 +419,55 @@ client = Ctxos( ) # Override per-request: -client.with_options(timeout=5.0).complete.create( +client.with_options(timeout=5 * 1000).complete.create( + prompt="Where can I get a good coffee in my neighbourhood?", model="ctxos-1", - prompt="prompt", ) ``` On timeout, an `APITimeoutError` is thrown. -Note that requests that time out are [retried twice by default](#retries). - -## Advanced +Note that requests which time out will be [retried twice by default](#retries). -### 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`: - -```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}.') -``` +## Default Headers -### Accessing raw response data (e.g. headers) +If you need to, you can override it by setting default headers per-request or on the client object. -The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., - -```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 +## Advanced: Configuring custom URLs, proxies, and transports -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 - -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()`: +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. -```python -client.with_options(http_client=DefaultHttpxClient(...)) -``` +## Status -### Managing HTTP resources +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. -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 -``` - -## Versioning - -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). diff --git a/bin/check-release-environment b/bin/check-release-environment old mode 100644 new mode 100755 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..c7e7040 --- /dev/null +++ b/examples/demo_async.py @@ -0,0 +1,19 @@ +#!/usr/bin/env poetry run python + +import asyncio + +from ctxos import AsyncCtxos + + +async def main() -> None: + client = AsyncCtxos() + + res = await client.complete.create( + model="ctxos-1", + prompt="how does a court case get to the Supreme Court?", + max_tokens=1000, + ) + print(res.choices[0].text) # type: ignore[index] + + +asyncio.run(main()) diff --git a/examples/demo_sync.py b/examples/demo_sync.py new file mode 100644 index 0000000..2a846ca --- /dev/null +++ b/examples/demo_sync.py @@ -0,0 +1,17 @@ +#!/usr/bin/env poetry run python + +from ctxos import Ctxos + + +def main() -> None: + client = Ctxos() + + res = client.complete.create( + model="ctxos-1", + prompt="how does a court case get to the Supreme Court?", + max_tokens=1000, + ) + print(res.choices[0].text) # type: ignore[index] + + +main() diff --git a/examples/streaming.py b/examples/streaming.py new file mode 100644 index 0000000..1eaee55 --- /dev/null +++ b/examples/streaming.py @@ -0,0 +1,46 @@ +#!/usr/bin/env poetry run python + +import asyncio + +from ctxos import Ctxos, AsyncCtxos, APIStatusError + +client = Ctxos() +async_client = AsyncCtxos() + +question = """ +Hey Ctxos! How can I recursively list all files in a directory in Python? +""" + + +def sync_request() -> None: + response = client.complete.create( + prompt=question, + model="ctxos-1", + max_tokens=300, + ) + print(response.choices[0].text) # type: ignore[index] + + +async def async_request() -> None: + response = await async_client.complete.create( + prompt=question, + model="ctxos-1", + max_tokens=300, + ) + print(response.choices[0].text) # type: ignore[index] + + +def request_error() -> None: + try: + client.complete.create( + prompt=question, + model="Ctxos-unknown-model", + max_tokens=300, + ) + except APIStatusError as err: + print(f"Caught API status error with response body: {err.response.text}") + + +sync_request() +asyncio.run(async_request()) +request_error() diff --git a/examples/tokens.py b/examples/tokens.py new file mode 100644 index 0000000..04bf6cb --- /dev/null +++ b/examples/tokens.py @@ -0,0 +1,30 @@ +#!/usr/bin/env poetry run python + +import asyncio + +from ctxos import Ctxos, AsyncCtxos + + +def sync_tokens() -> None: + client = Ctxos() + + text = "hello world!" + + tokens = client.tokens.count(input=text) + print(f"'{text}' is {tokens.tokens} tokens") + + +async def async_tokens() -> None: + ctxos = AsyncCtxos() + + text = "first message" + tokens = await ctxos.tokens.count(input=text) + print(f"'{text}' is {tokens.tokens} tokens") + + text = "second message" + tokens = await ctxos.tokens.count(input=text) + print(f"'{text}' is {tokens.tokens} tokens") + + +sync_tokens() +asyncio.run(async_tokens()) diff --git a/src/ctxos/__init__.py b/src/ctxos/__init__.py index a154bde..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 @@ -31,6 +32,8 @@ __all__ = [ "types", + "BaseTool", + "ToolUser", "__version__", "__title__", "NoneType", diff --git a/src/ctxos/_tokenizers.py b/src/ctxos/_tokenizers.py new file mode 100644 index 0000000..52942ce --- /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, reportReturnType=false +from tokenizers import Tokenizer # type: ignore[import, reportMissingTypeStubs] + + +def _get_tokenizer_cache_path() -> Path: + return Path(__file__).parent / "tokenizer.json" + + +_tokenizer: Tokenizer | None = None + + +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": # type: ignore[return-value] + 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": # type: ignore[return-value] + 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/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/tools/__init__.py b/src/ctxos/tools/__init__.py new file mode 100644 index 0000000..98be353 --- /dev/null +++ b/src/ctxos/tools/__init__.py @@ -0,0 +1,212 @@ +import json +from typing import Any, Dict, List, Optional + +__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 + """ + from .. import Ctxos + + 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 not choice: + 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_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_text, + } + ) + else: + messages.append(tool_inputs_message) + + return messages 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, + }, + } 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" },