diff --git a/README.md b/README.md index 8cda3e4..6c91e8c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ OpenGradient enables developers to build AI applications with verifiable executi ### Key Features - **Verifiable LLM Inference**: Drop-in replacement for OpenAI and Anthropic APIs with cryptographic attestation +- **Confidential Inference (Oblivious HTTP)**: End-to-end encrypted chat through an untrusted relay, with the enclave signature verified client-side before any content is returned — no wallet needed on the caller - **Multi-Provider Support**: Access models from OpenAI, Anthropic, Google, and xAI through a unified interface - **Native Web Search**: Opt-in `web_search` flag enables each provider's built-in web search, billed per search - **Image Generation**: Native image-output models ("nano banana") return generated images directly on the response @@ -215,6 +216,52 @@ for i, image in enumerate(result.images or []): f.write(base64.b64decode(payload)) ``` +### Confidential Inference (Oblivious HTTP) + +`og.ConfidentialLLM` sends chat completions end-to-end encrypted to a TEE through an untrusted relay — the same Oblivious HTTP path the OpenGradient chat app uses in the browser. The request is HPKE-encrypted so the relay only ever sees ciphertext, and the enclave's signature is **verified client-side before any content is returned**. Unlike `og.LLM`, this path needs **no wallet on the caller**: the relay holds the x402 account and pays per request. + +It auto-resolves an OHTTP-capable TEE from the on-chain registry and appends the confidential-inference path (`/api/v1/chat/ohttp`) to the relay URL for you. `chat()` is synchronous. + +```python +import opengradient as og + +client = og.ConfidentialLLM( + relay_url="https://chat-api.opengradient.ai", + # If the relay requires authentication, provide a per-request header callable: + auth_headers=lambda: {"Authorization": "Bearer "}, +) + +result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "In one sentence, what is a TEE?"}], + max_tokens=200, +) + +print(result.content) # verified assistant text +print(result.proof.tee_id) # the attested enclave that produced it +``` + +Set `stream=True` to buffer, verify, and return the decrypted SSE frames in `result.stream_frames`. To target a self-hosted or pre-selected enclave and skip the registry lookup, use `og.ConfidentialLLM.from_tee(relay_url, tee)`. + +#### Sign in with your Chat account + +Instead of managing a relay URL and token yourself, `og.login_chat_account()` signs you in through the browser — the same CLI-auth flow other OpenGradient tools use. It opens `chat.opengradient.ai/cli-auth`, waits for you to authorize on a short-lived loopback listener (the session is only ever posted back to `127.0.0.1`, never to a remote host), and returns the access token together with the relay URL to use: + +```python +auth = og.login_chat_account() # opens the browser, waits for sign-in + +client = og.ConfidentialLLM( + relay_url=auth.chat_api_base_url, # relay URL from the account config + auth_headers=auth.auth_headers, # Bearer , refreshed per request +) + +result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "Hello!"}], +) +print(result.content) +``` + ### Verifiable LangChain Integration Use OpenGradient as a drop-in LLM provider for LangChain agents with network-verified execution: diff --git a/docs/opengradient/client/chat_auth.md b/docs/opengradient/client/chat_auth.md new file mode 100644 index 0000000..82f62ce --- /dev/null +++ b/docs/opengradient/client/chat_auth.md @@ -0,0 +1,151 @@ +--- +outline: [2,4] +--- + +[opengradient](../index) / [client](./index) / chat_auth + +# Package opengradient.client.chat_auth + +Browser-based login to an OpenGradient Chat account. + +Opens the chat app's CLI-authorization page (``…/cli-auth``) in the user's +browser, runs a short-lived loopback HTTP listener to receive the session the +page hands back, and returns it. This mirrors the flow the chat app implements +in ``app/(standalone)/cli-auth`` (``CliAuth.tsx``): the page only ever POSTs the +session to a loopback (``127.0.0.1`` / ``localhost``) address, so the token never +leaves the user's machine. + +The returned `ChatAccountAuth` carries the access token **and** the public client +config the SDK needs to reach the confidential-inference relay, so it drops +straight into [ConfidentialLLM](./confidential_llm): + + ```python + import opengradient as og + + auth = og.login_chat_account() # opens the browser, waits for sign-in + client = og.ConfidentialLLM( + relay_url=auth.chat_api_base_url, # from the returned config + auth_headers=auth.auth_headers, # Bearer + ) + result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "Hello!"}], + ) + print(result.content) + ``` + +## Functions + +--- + +### `login_chat_account()` + +```python +def login_chat_account( + chat_app_url: str = 'https://chat.opengradient.ai', + *, + app_name: str = 'opengradient-sdk', + timeout: float = 300.0, + open_browser: bool = True, + host: str = '127.0.0.1', + port: int = 0, + on_ready: Optional[Callable[[str], None]] = None +) ‑> `ChatAccountAuth` +``` +Sign in to an OpenGradient Chat account via the browser and return the session. + +Starts a loopback HTTP listener, opens the chat app's ``/cli-auth`` page +pointed at it, and blocks until the page posts back the session bundle (after +the user signs in and authorizes) or ``timeout`` elapses. + +**Arguments** + +* **`chat_app_url`**: Base URL of the chat app hosting ``/cli-auth``. Defaults to + ``https://chat.opengradient.ai``. +* **`app_name`**: Name shown to the user on the authorization page. +* **`timeout`**: Seconds to wait for the user to authorize before giving up. +* **`open_browser`**: Open the authorization URL in the default browser. When + False (or if no browser is available), the URL is surfaced via + ``on_ready`` / logged so the user can open it manually. +* **`host`**: Loopback host to bind the listener to (``127.0.0.1`` or + ``localhost`` — the page rejects any non-loopback callback). +* **`port`**: Loopback port to bind. ``0`` picks a free ephemeral port. +* **`on_ready`**: Optional callback invoked with the full authorization URL once + the listener is up. Defaults to logging it. Use this to surface the + URL yourself (e.g. print a QR code). + +**Returns** + +A `ChatAccountAuth` with the session tokens and client config. + +**`ChatAccountAuth` fields:** + +* **`access_token`**: The bearer token to authenticate to the chat-api relay. +* **`refresh_token`**: The Supabase refresh token (for obtaining a new access + token when this one expires). +* **`token_type`**: The token type reported by Supabase (typically ``"bearer"``). +* **`expires_at`**: Unix seconds at which ``access_token`` expires, if known. +* **`expires_in`**: Seconds until expiry at issue time, if known. +* **`user`**: The signed-in user record (``id``, ``email``, ``is_anonymous``). +* **`config`**: Public client configuration (``chat_api_base_url``, + ``tee_registry_rpc_url``, ``tee_registry_address``, ...). +* **`raw`**: The full bundle exactly as received, for forward compatibility. + +**Raises** + +* **`TimeoutError`**: If the user did not authorize within ``timeout`` seconds. +* **`ValueError`**: If the received bundle was malformed. + +## Classes + +### `ChatAccountAuth` + +A signed-in OpenGradient Chat session handed back by the browser. + +Mirrors the chat app's ``CliAuthBundle``. Besides the Supabase session +tokens, it carries the public ``config`` the CLI/SDK needs to talk to +chat-api and read the TEE registry — a single source of truth so nothing has +to be hardcoded. + +**Attributes** + +* **`access_token`**: The bearer token to authenticate to the chat-api relay. +* **`refresh_token`**: The Supabase refresh token (for obtaining a new access + token when this one expires). +* **`token_type`**: The token type reported by Supabase (typically ``"bearer"``). +* **`expires_at`**: Unix seconds at which ``access_token`` expires, if known. +* **`expires_in`**: Seconds until expiry at issue time, if known. +* **`user`**: The signed-in user record (``id``, ``email``, ``is_anonymous``). +* **`config`**: Public client configuration (``chat_api_base_url``, + ``tee_registry_rpc_url``, ``tee_registry_address``, ...). +* **`raw`**: The full bundle exactly as received, for forward compatibility. + +#### Constructor + +```python +def __init__( + access_token: str, + refresh_token: str, + token_type: str, + expires_at: Optional[int], + expires_in: Optional[int], + user: Dict[str, Any] = <factory>, + config: Dict[str, Any] = <factory>, + raw: Dict[str, Any] = <factory> +) +``` + +#### Methods + +--- + +#### `auth_headers()` + +```python +def auth_headers(self) ‑> Dict[str, str] +``` +Return the ``Authorization`` header for the relay. + +Shaped as a zero-argument callable so it can be handed directly to +`ConfidentialLLM(auth_headers=...)` / `OhttpRelayClient`, which call it +per request. \ No newline at end of file diff --git a/docs/opengradient/client/confidential_llm.md b/docs/opengradient/client/confidential_llm.md new file mode 100644 index 0000000..952d480 --- /dev/null +++ b/docs/opengradient/client/confidential_llm.md @@ -0,0 +1,227 @@ +--- +outline: [2,4] +--- + +[opengradient](../index) / [client](./index) / confidential_llm + +# Package opengradient.client.confidential_llm + +High-level confidential (Oblivious HTTP) LLM inference. + +This is the one-call entry point for verified, private chat completions over +Oblivious HTTP — the same path the OpenGradient chat app uses in the browser +(``lib/api/ohttp.ts``). It ties together the pieces an integrator would +otherwise have to wire up by hand: + + 1. [tee_registry](./tee_registry) — discover an OHTTP-capable TEE + (endpoint, HPKE key, signing key) from the on-chain registry. + 2. [tee_ohttp_client](./tee_ohttp_client) — HPKE-encrypt the request, POST the + ciphertext to the relay's confidential-inference path + (``/api/v1/chat/ohttp``), then decrypt and verify the response. + +Unlike [LLM](./llm), this client needs **no wallet on the +caller's side**: the request travels end-to-end encrypted to the enclave, and +the untrusted relay (which holds the x402 account and pays per request) only +ever sees ciphertext. Authentication to the relay is left to the caller via an +``auth_headers`` provider, so the client works against any relay deployment +without baking in a credential scheme. + +Every response is signature-verified inside the client before any content is +returned — see [tee_verify](./tee_verify) for the trust chain. + +Usage: + ```python + import opengradient as og + + # Resolve an OHTTP-capable TEE from the on-chain registry and target the + # relay's confidential-inference path automatically. + client = og.ConfidentialLLM( + relay_url="https://chat-api.opengradient.ai", + auth_headers=lambda: {"Authorization": "Bearer "}, + ) + + result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "Hello!"}], + max_tokens=200, + ) + print(result.content) # verified assistant text + print(result.proof.tee_id) # attested enclave identity + ``` + +## Classes + +### `ConfidentialLLM` + +Verified, private chat completions over Oblivious HTTP through a relay. + +Discovers an OHTTP-capable TEE from the on-chain registry, POSTs +HPKE-encrypted requests to the relay's confidential-inference path +(``/api/v1/chat/ohttp``), and verifies the enclave's signature before +returning any content — matching the browser chat app's OHTTP flow. + +**Raises** + +* **`RuntimeError`**: If the registry has no active OHTTP-capable LLM TEE. + +#### Constructor + +```python +def __init__( + relay_url: str, + *, + rpc_url: str = 'https://ogevmdevnet.opengradient.ai', + registry_address: str = '0x703cB174AEadB35D611858369B4b1111dC9Abda6', + auth_headers: Optional[AuthHeaderProvider] = None, + session: Optional[`Session`] = None, + timeout: float = 120.0 +) +``` + +**Arguments** + +* **`relay_url`**: The relay base URL (e.g. ``"https://chat-api.opengradient.ai"``) + or the full confidential-inference URL. The + ``/api/v1/chat/ohttp`` path is appended automatically when the given + URL does not already end with it. +* **`rpc_url`**: RPC endpoint for the chain the TEE registry is deployed on. + Defaults to the OpenGradient devnet. +* **`registry_address`**: Address of the deployed ``TEERegistry`` contract. +* **`auth_headers`**: Optional callable returning headers to authenticate to the + relay (called per request so tokens can be refreshed), e.g. + ``lambda: {"Authorization": "Bearer "}``. +* **`session`**: Optional ``requests.Session`` to reuse connections. +* **`timeout`**: Per-request timeout in seconds. + +#### Static methods + +--- + +#### `from_tee()` + +```python +static def from_tee( + relay_url: str, + tee: TEEEndpoint, + *, + auth_headers: Optional[AuthHeaderProvider] = None, + session: Optional[`Session`] = None, + timeout: float = 120.0 +) ‑> `ConfidentialLLM` +``` +Create a client for a TEE you have already resolved. + +Use this to skip the registry lookup — for a self-hosted TEE, a pinned +enclave, or when you have already selected a `TEEEndpoint` (which must +carry an ``ohttp_config`` and ``signing_public_key_der``). + +**Arguments** + +* **`relay_url`**: The relay base URL or full confidential-inference URL (the + ``/api/v1/chat/ohttp`` path is appended when missing). +* **`tee`**: The `opengradient.client.tee_registry.TEEEndpoint` to encrypt to. +* **`auth_headers`**: Optional per-request relay auth header provider. +* **`session`**: Optional ``requests.Session`` to reuse connections. +* **`timeout`**: Per-request timeout in seconds. + +#### Methods + +--- + +#### `chat()` + +```python +def chat( + self, + model: "Union['TEE_LLM', str]", + messages: List[Dict], + max_tokens: int = 100, + stop_sequence: Optional[List[str]] = None, + temperature: float = 0.0, + tools: Optional[List[Dict]] = None, + tool_choice: Optional[str] = None, + response_format: Optional[Dict] = None, + web_search: bool = False, + stream: bool = False +) ‑> VerifiedChatResponse +``` +Send a verified, private chat completion over Oblivious HTTP. + +Builds an OpenAI ``/v1/chat/completions`` request from the arguments (the +same shape the chat app builds in ``buildInnerChatRequest``), encrypts +it to the resolved TEE, and returns the signature-verified response. + +**Arguments** + +* **`model`**: The model to use, e.g. ``og.TEE_LLM.CLAUDE_HAIKU_4_5``. A + ``"provider/model"`` value is reduced to the gateway model id + (the part after the ``/``), mirroring the chat app. +* **`messages`**: OpenAI-style chat messages. +* **`max_tokens`**: Maximum output tokens. Default 100. +* **`stop_sequence`**: Optional list of stop sequences. +* **`temperature`**: Sampling temperature (0-1). Default 0.0. +* **`tools`**: Optional function-calling tool definitions. +* **`tool_choice`**: Optional tool-choice directive (forwarded on the wire but + not part of the signed request hash). +* **`response_format`**: Optional OpenAI ``response_format`` dict. +* **`web_search`**: Enable the provider's native web search. Default False. +* **`stream`**: When True, the encrypted stream is fully buffered and verified + before returning; ``result.stream_frames`` holds the decrypted SSE + frames. Default False. + +**Returns** + +A `opengradient.client.tee_ohttp_client.VerifiedChatResponse`. + +--- + +#### `chat_completion()` + +```python +def chat_completion(self, body: Dict) ‑> `VerifiedChatResponse` +``` +Send a raw OpenAI chat-completions ``body`` (non-streaming) and verify it. + +Escape hatch for callers that build the request body themselves. See +`chat` for the argument-driven convenience. + +**Returns** + +**`VerifiedChatResponse` fields:** + +* **`body`**: The inner response JSON (the single-shot body, or the final SSE + frame for a stream). +* **`content`**: The assistant text (or tool-calls JSON) that was verified. +* **`proof`**: The :class:`opengradient.client.tee_verify.TeeProof`. +* **`stream_frames`**: For streaming requests, the decrypted inner SSE ``data:`` + event strings (already verified), ready to replay to a client; + ``None`` for single-shot requests. + +--- + +#### `stream_chat_completion()` + +```python +def stream_chat_completion(self, body: Dict) ‑> `VerifiedChatResponse` +``` +Send a raw OpenAI chat-completions ``body`` as a stream and verify it. + +The encrypted stream is fully buffered and verified before returning, so +``result.stream_frames`` is safe to replay to an end user. + +**Returns** + +**`VerifiedChatResponse` fields:** + +* **`body`**: The inner response JSON (the single-shot body, or the final SSE + frame for a stream). +* **`content`**: The assistant text (or tool-calls JSON) that was verified. +* **`proof`**: The :class:`opengradient.client.tee_verify.TeeProof`. +* **`stream_frames`**: For streaming requests, the decrypted inner SSE ``data:`` + event strings (already verified), ready to replay to a client; + ``None`` for single-shot requests. + +#### Variables + +* `relay_url` : str - The full confidential-inference URL requests are POSTed to. +* [**`tee`**](./tee_registry): The resolved TEE this client encrypts requests to. \ No newline at end of file diff --git a/docs/opengradient/client/index.md b/docs/opengradient/client/index.md index 634d947..9735fae 100644 --- a/docs/opengradient/client/index.md +++ b/docs/opengradient/client/index.md @@ -11,6 +11,8 @@ OpenGradient Client -- service modules for the SDK. ## Modules - **[llm](./llm)** -- LLM chat and text completion with TEE-verified execution and x402 payment settlement (Base OPG tokens) +- **[confidential_llm](./confidential_llm)** -- One-call confidential (Oblivious HTTP) chat: auto-resolves an OHTTP-capable TEE and verifies the response, no wallet needed on the caller +- **[chat_auth](./chat_auth)** -- Browser-based login to an OpenGradient Chat account (the CLI-auth flow): returns the access token and client config for the confidential-inference relay - **[model_hub](./model_hub)** -- Model repository management: create, version, and upload ML models - **[alpha](./alpha)** -- Alpha Testnet features: on-chain ONNX model inference (VANILLA, TEE, ZKML modes), workflow deployment, and scheduled ML model execution (OpenGradient testnet gas tokens) - **[twins](./twins)** -- Digital twins chat via OpenGradient verifiable inference @@ -70,6 +72,66 @@ hashes (attachment bytes stripped). Pass ``canonical`` to --- +### `login_chat_account()` + +```python +def login_chat_account( + chat_app_url: str = 'https://chat.opengradient.ai', + *, + app_name: str = 'opengradient-sdk', + timeout: float = 300.0, + open_browser: bool = True, + host: str = '127.0.0.1', + port: int = 0, + on_ready: Optional[Callable[[str], None]] = None +) ‑> `ChatAccountAuth` +``` +Sign in to an OpenGradient Chat account via the browser and return the session. + +Starts a loopback HTTP listener, opens the chat app's ``/cli-auth`` page +pointed at it, and blocks until the page posts back the session bundle (after +the user signs in and authorizes) or ``timeout`` elapses. + +**Arguments** + +* **`chat_app_url`**: Base URL of the chat app hosting ``/cli-auth``. Defaults to + ``https://chat.opengradient.ai``. +* **`app_name`**: Name shown to the user on the authorization page. +* **`timeout`**: Seconds to wait for the user to authorize before giving up. +* **`open_browser`**: Open the authorization URL in the default browser. When + False (or if no browser is available), the URL is surfaced via + ``on_ready`` / logged so the user can open it manually. +* **`host`**: Loopback host to bind the listener to (``127.0.0.1`` or + ``localhost`` — the page rejects any non-loopback callback). +* **`port`**: Loopback port to bind. ``0`` picks a free ephemeral port. +* **`on_ready`**: Optional callback invoked with the full authorization URL once + the listener is up. Defaults to logging it. Use this to surface the + URL yourself (e.g. print a QR code). + +**Returns** + +A `ChatAccountAuth` with the session tokens and client config. + +**`ChatAccountAuth` fields:** + +* **`access_token`**: The bearer token to authenticate to the chat-api relay. +* **`refresh_token`**: The Supabase refresh token (for obtaining a new access + token when this one expires). +* **`token_type`**: The token type reported by Supabase (typically ``"bearer"``). +* **`expires_at`**: Unix seconds at which ``access_token`` expires, if known. +* **`expires_in`**: Seconds until expiry at issue time, if known. +* **`user`**: The signed-in user record (``id``, ``email``, ``is_anonymous``). +* **`config`**: Public client configuration (``chat_api_base_url``, + ``tee_registry_rpc_url``, ``tee_registry_address``, ...). +* **`raw`**: The full bundle exactly as received, for forward compatibility. + +**Raises** + +* **`TimeoutError`**: If the user did not authorize within ``timeout`` seconds. +* **`ValueError`**: If the received bundle was malformed. + +--- + ### `verify_response()` ```python @@ -120,6 +182,58 @@ A :class:`TeeProof` describing the verified provenance. ## Classes +### `ChatAccountAuth` + +A signed-in OpenGradient Chat session handed back by the browser. + +Mirrors the chat app's ``CliAuthBundle``. Besides the Supabase session +tokens, it carries the public ``config`` the CLI/SDK needs to talk to +chat-api and read the TEE registry — a single source of truth so nothing has +to be hardcoded. + +**Attributes** + +* **`access_token`**: The bearer token to authenticate to the chat-api relay. +* **`refresh_token`**: The Supabase refresh token (for obtaining a new access + token when this one expires). +* **`token_type`**: The token type reported by Supabase (typically ``"bearer"``). +* **`expires_at`**: Unix seconds at which ``access_token`` expires, if known. +* **`expires_in`**: Seconds until expiry at issue time, if known. +* **`user`**: The signed-in user record (``id``, ``email``, ``is_anonymous``). +* **`config`**: Public client configuration (``chat_api_base_url``, + ``tee_registry_rpc_url``, ``tee_registry_address``, ...). +* **`raw`**: The full bundle exactly as received, for forward compatibility. + +#### Constructor + +```python +def __init__( + access_token: str, + refresh_token: str, + token_type: str, + expires_at: Optional[int], + expires_in: Optional[int], + user: Dict[str, Any] = <factory>, + config: Dict[str, Any] = <factory>, + raw: Dict[str, Any] = <factory> +) +``` + +#### Methods + +--- + +#### `auth_headers()` + +```python +def auth_headers(self) ‑> Dict[str, str] +``` +Return the ``Authorization`` header for the relay. + +Shaped as a zero-argument callable so it can be handed directly to +`ConfidentialLLM(auth_headers=...)` / `OhttpRelayClient`, which call it +per request. + ### `OhttpRelayClient` Send verified, private chat completions to a TEE through an OHTTP relay. diff --git a/docs/opengradient/index.md b/docs/opengradient/index.md index 810dd2c..4870b40 100644 --- a/docs/opengradient/index.md +++ b/docs/opengradient/index.md @@ -20,6 +20,8 @@ inference was performed correctly. The SDK operates across two chains with separate private keys: - **[llm](./client/llm)** (``og.LLM``) -- LLM chat and completion with TEE-verified execution. Pays via x402 on **Base** (requires OPG tokens). +- **[confidential_llm](./client/confidential_llm)** (``og.ConfidentialLLM``) -- Confidential (Oblivious HTTP) chat: end-to-end encrypted to the enclave and signature-verified, with no wallet on the caller (an untrusted relay pays via x402 and only sees ciphertext). +- **[chat_auth](./client/chat_auth)** (``og.login_chat_account``) -- Browser-based login to an OpenGradient Chat account: opens the CLI-auth page and returns the access token plus the relay config for ``og.ConfidentialLLM``. - **[alpha](./client/alpha)** (``og.Alpha``) -- On-chain ONNX model inference with VANILLA, TEE, or ZKML verification. Pays gas on the **OpenGradient alpha testnet**. - **[model_hub](./client/model_hub)** (``og.ModelHub``) -- Model repository management: create, version, and upload ML models. Requires email/password auth. - **[twins](./client/twins)** (``og.Twins``) -- Digital twins chat via verifiable inference. Requires a twins API key. diff --git a/docs/opengradient/types.md b/docs/opengradient/types.md index 6cf8d44..5c95d89 100644 --- a/docs/opengradient/types.md +++ b/docs/opengradient/types.md @@ -494,6 +494,8 @@ auditability and tamper-proof AI inference. * static `GEMINI_3_1_PRO_PREVIEW` * static `GEMINI_3_5_FLASH` * static `GEMINI_3_FLASH` +* static `GLM_5_2` +* static `GLM_IMAGE` * static `GPT_4_1_2025_04_14` * static `GPT_4_1_MINI` * static `GPT_4_1_NANO` @@ -504,6 +506,8 @@ auditability and tamper-proof AI inference. * static `GPT_5_4_NANO` * static `GPT_5_5` * static `GPT_5_MINI` +* static `GPT_IMAGE_2` +* static `GROK_2_IMAGE` * static `GROK_4` * static `GROK_4_1_FAST` * static `GROK_4_1_FAST_NON_REASONING` @@ -515,6 +519,8 @@ auditability and tamper-proof AI inference. * static `HERMES_4_70B` * static `O3` * static `O4_MINI` +* static `SEEDANCE_4_5` +* static `SEEDREAM_4_0` * static `SEED_1_6` * static `SEED_1_8` * static `SEED_2_0_LITE` diff --git a/examples/README.md b/examples/README.md index c941cf6..1818f73 100644 --- a/examples/README.md +++ b/examples/README.md @@ -108,6 +108,19 @@ python examples/llm_image_generation.py - Reads the generated images from `result.images` (data URIs) and writes them to disk - Image output is billed as completion tokens; images travel out-of-band and are not part of the signed output hash +#### `confidential_chat.py` +Runs a confidential (Oblivious HTTP) chat completion through an untrusted relay. + +```bash +python examples/confidential_chat.py +``` + +**What it does:** +- Uses `og.ConfidentialLLM`, which auto-resolves an OHTTP-capable TEE from the on-chain registry and targets the relay's confidential-inference path (`/api/v1/chat/ohttp`) — the same OHTTP flow the browser chat app uses +- Sends the request end-to-end encrypted (HPKE): the relay holds the x402 account and only ever sees ciphertext, so no wallet is needed on this side +- Verifies the enclave's RSA-PSS signature before returning any content, and exposes the provenance via `result.proof` +- By default signs in through the browser with `og.login_chat_account()` (the CLI-auth flow) and uses the account's own relay URL and token; set `OG_RELAY_URL` (and optional `OG_RELAY_TOKEN`) to skip the browser and point at a relay directly + ## Alpha Testnet Examples Examples for features only available on the **Alpha Testnet** are located in the [`alpha/`](./alpha/) folder. These include: diff --git a/examples/confidential_chat.py b/examples/confidential_chat.py new file mode 100644 index 0000000..0272c29 --- /dev/null +++ b/examples/confidential_chat.py @@ -0,0 +1,63 @@ +"""Confidential (Oblivious HTTP) chat. + +Sends a chat completion end-to-end encrypted to a TEE through an untrusted +relay, then verifies the enclave's signature before printing the answer — the +same OHTTP path the OpenGradient chat app uses in the browser. No wallet is +needed on this side: the relay holds the x402 account and only ever sees +ciphertext. + +By default this signs you in through the browser (og.login_chat_account) and +uses the account's own relay URL and token — the same CLI-auth flow other +OpenGradient tools use. Set OG_RELAY_URL / OG_RELAY_TOKEN to skip the browser +and point at a relay directly instead. +""" + +import logging +import os + +import opengradient as og + +logging.basicConfig() +logging.getLogger("opengradient").setLevel(logging.INFO) + + +def main(): + relay_url = os.environ.get("OG_RELAY_URL") + token = os.environ.get("OG_RELAY_TOKEN") + + if relay_url: + # Direct: point at a relay yourself (the /api/v1/chat/ohttp path is + # appended automatically). + auth_headers = (lambda: {"Authorization": f"Bearer {token}"}) if token else None + else: + # Browser login: opens chat.opengradient.ai/cli-auth, waits for sign-in, + # and hands back the access token plus the relay URL to use. + auth = og.login_chat_account() + relay_url = auth.chat_api_base_url + auth_headers = auth.auth_headers + if not relay_url: + raise SystemExit("The chat account did not return a relay URL; set OG_RELAY_URL instead.") + + # Resolves an OHTTP-capable TEE from the on-chain registry and targets the + # relay's confidential-inference path automatically. + client = og.ConfidentialLLM( + relay_url=relay_url, + auth_headers=auth_headers, + ) + + result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "In one sentence, what is a TEE?"}], + max_tokens=200, + ) + + print(result.content) + + # The response is cryptographically verified: the proof ties this exact + # output to the attested enclave that produced it. + print("\n" + "=" * 40) + print(f"Verified TEE: {result.proof.tee_id}") + print(f"Signed at: {result.proof.timestamp}") + + +main() diff --git a/src/opengradient/__init__.py b/src/opengradient/__init__.py index e035090..9179d50 100644 --- a/src/opengradient/__init__.py +++ b/src/opengradient/__init__.py @@ -11,6 +11,8 @@ The SDK operates across two chains with separate private keys: - **`opengradient.client.llm`** (``og.LLM``) -- LLM chat and completion with TEE-verified execution. Pays via x402 on **Base** (requires OPG tokens). +- **`opengradient.client.confidential_llm`** (``og.ConfidentialLLM``) -- Confidential (Oblivious HTTP) chat: end-to-end encrypted to the enclave and signature-verified, with no wallet on the caller (an untrusted relay pays via x402 and only sees ciphertext). +- **`opengradient.client.chat_auth`** (``og.login_chat_account``) -- Browser-based login to an OpenGradient Chat account: opens the CLI-auth page and returns the access token plus the relay config for ``og.ConfidentialLLM``. - **`opengradient.client.alpha`** (``og.Alpha``) -- On-chain ONNX model inference with VANILLA, TEE, or ZKML verification. Pays gas on the **OpenGradient alpha testnet**. - **`opengradient.client.model_hub`** (``og.ModelHub``) -- Model repository management: create, version, and upload ML models. Requires email/password auth. - **`opengradient.client.twins`** (``og.Twins``) -- Digital twins chat via verifiable inference. Requires a twins API key. @@ -80,6 +82,8 @@ async def stream_example(): from .client import ( LLM, Alpha, + ChatAccountAuth, + ConfidentialLLM, ModelHub, OhttpRelayClient, RelayError, @@ -90,6 +94,7 @@ async def stream_example(): VerificationError, VerifiedChatResponse, build_inner_request, + login_chat_account, verify_response, ) from .types import ( @@ -126,6 +131,12 @@ async def stream_example(): "x402SettlementMode", "agents", "alphasense", + # Confidential inference (Oblivious HTTP) — verified, private chat with no + # wallet needed on the caller (the relay pays via x402). + "ConfidentialLLM", + # Browser login to an OpenGradient Chat account (CLI-auth flow). + "login_chat_account", + "ChatAccountAuth", # Verified-inference building blocks "TEERegistry", "TEEEndpoint", @@ -151,6 +162,9 @@ async def stream_example(): "Alpha": False, "ModelHub": False, "Twins": False, + "ConfidentialLLM": False, + "login_chat_account": False, + "ChatAccountAuth": False, "TEE_LLM": False, "InferenceMode": False, "TextGenerationOutput": False, diff --git a/src/opengradient/client/__init__.py b/src/opengradient/client/__init__.py index b5ea1cd..feb8697 100644 --- a/src/opengradient/client/__init__.py +++ b/src/opengradient/client/__init__.py @@ -4,6 +4,8 @@ ## Modules - **`opengradient.client.llm`** -- LLM chat and text completion with TEE-verified execution and x402 payment settlement (Base OPG tokens) +- **`opengradient.client.confidential_llm`** -- One-call confidential (Oblivious HTTP) chat: auto-resolves an OHTTP-capable TEE and verifies the response, no wallet needed on the caller +- **`opengradient.client.chat_auth`** -- Browser-based login to an OpenGradient Chat account (the CLI-auth flow): returns the access token and client config for the confidential-inference relay - **`opengradient.client.model_hub`** -- Model repository management: create, version, and upload ML models - **`opengradient.client.alpha`** -- Alpha Testnet features: on-chain ONNX model inference (VANILLA, TEE, ZKML modes), workflow deployment, and scheduled ML model execution (OpenGradient testnet gas tokens) - **`opengradient.client.twins`** -- Digital twins chat via OpenGradient verifiable inference @@ -31,6 +33,8 @@ """ from .alpha import Alpha +from .chat_auth import ChatAccountAuth, login_chat_account +from .confidential_llm import ConfidentialLLM from .llm import LLM from .model_hub import ModelHub from .tee_ohttp_client import OhttpRelayClient, RelayError, VerifiedChatResponse @@ -43,6 +47,13 @@ "Alpha", "ModelHub", "Twins", + # Confidential inference: one-call verified, private chat over Oblivious HTTP + # (auto-resolves an OHTTP-capable TEE from the registry, like the chat app). + "ConfidentialLLM", + # Browser-based login to an OpenGradient Chat account (the CLI-auth flow): + # returns the access token + client config, ready for ConfidentialLLM. + "login_chat_account", + "ChatAccountAuth", # Verified-inference building blocks: route an OpenAI-style request to a TEE # through an untrusted relay, then cryptographically verify the response. "TEERegistry", @@ -61,6 +72,7 @@ "LLM": False, "ModelHub": False, "Twins": False, + "ConfidentialLLM": False, "client": False, "exceptions": False, "opg_token": False, @@ -68,4 +80,6 @@ "tee_ohttp": True, "tee_verify": True, "tee_ohttp_client": True, + "confidential_llm": True, + "chat_auth": True, } diff --git a/src/opengradient/client/chat_auth.py b/src/opengradient/client/chat_auth.py new file mode 100644 index 0000000..4bbe6ba --- /dev/null +++ b/src/opengradient/client/chat_auth.py @@ -0,0 +1,274 @@ +"""Browser-based login to an OpenGradient Chat account. + +Opens the chat app's CLI-authorization page (``…/cli-auth``) in the user's +browser, runs a short-lived loopback HTTP listener to receive the session the +page hands back, and returns it. This mirrors the flow the chat app implements +in ``app/(standalone)/cli-auth`` (``CliAuth.tsx``): the page only ever POSTs the +session to a loopback (``127.0.0.1`` / ``localhost``) address, so the token never +leaves the user's machine. + +The returned `ChatAccountAuth` carries the access token **and** the public client +config the SDK needs to reach the confidential-inference relay, so it drops +straight into `opengradient.client.confidential_llm.ConfidentialLLM`: + + ```python + import opengradient as og + + auth = og.login_chat_account() # opens the browser, waits for sign-in + client = og.ConfidentialLLM( + relay_url=auth.chat_api_base_url, # from the returned config + auth_headers=auth.auth_headers, # Bearer + ) + result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "Hello!"}], + ) + print(result.content) + ``` +""" + +from __future__ import annotations + +import json +import logging +import time +import webbrowser +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any, Callable, Dict, Optional +from urllib.parse import urlencode + +logger = logging.getLogger(__name__) + +# The chat app that hosts the CLI-authorization page. +DEFAULT_CHAT_APP_URL = "https://chat.opengradient.ai" +# Path of the device-authorization page (see chat-app app/(standalone)/cli-auth). +CLI_AUTH_PATH = "/cli-auth" +# The ``type`` discriminator on the JSON bundle the page posts back. +BUNDLE_TYPE = "opengradient-cli-auth" + + +@dataclass(frozen=True) +class ChatAccountAuth: + """A signed-in OpenGradient Chat session handed back by the browser. + + Mirrors the chat app's ``CliAuthBundle``. Besides the Supabase session + tokens, it carries the public ``config`` the CLI/SDK needs to talk to + chat-api and read the TEE registry — a single source of truth so nothing has + to be hardcoded. + + Attributes: + access_token: The bearer token to authenticate to the chat-api relay. + refresh_token: The Supabase refresh token (for obtaining a new access + token when this one expires). + token_type: The token type reported by Supabase (typically ``"bearer"``). + expires_at: Unix seconds at which ``access_token`` expires, if known. + expires_in: Seconds until expiry at issue time, if known. + user: The signed-in user record (``id``, ``email``, ``is_anonymous``). + config: Public client configuration (``chat_api_base_url``, + ``tee_registry_rpc_url``, ``tee_registry_address``, ...). + raw: The full bundle exactly as received, for forward compatibility. + """ + + access_token: str + refresh_token: str + token_type: str + expires_at: Optional[int] + expires_in: Optional[int] + user: Dict[str, Any] = field(default_factory=dict) + config: Dict[str, Any] = field(default_factory=dict) + raw: Dict[str, Any] = field(default_factory=dict) + + @property + def email(self) -> Optional[str]: + """The signed-in account's email, if any.""" + value = self.user.get("email") + return value if isinstance(value, str) else None + + @property + def chat_api_base_url(self) -> Optional[str]: + """The relay base URL to pass to `ConfidentialLLM`.""" + value = self.config.get("chat_api_base_url") + return value if isinstance(value, str) else None + + @property + def tee_registry_rpc_url(self) -> Optional[str]: + """The RPC URL for the TEE registry the chat app is configured against.""" + value = self.config.get("tee_registry_rpc_url") + return value if isinstance(value, str) else None + + @property + def tee_registry_address(self) -> Optional[str]: + """The TEE registry contract address the chat app is configured against.""" + value = self.config.get("tee_registry_address") + return value if isinstance(value, str) else None + + @property + def is_expired(self) -> bool: + """Whether ``access_token`` is past its ``expires_at`` (False if unknown).""" + return self.expires_at is not None and time.time() >= self.expires_at + + def auth_headers(self) -> Dict[str, str]: + """Return the ``Authorization`` header for the relay. + + Shaped as a zero-argument callable so it can be handed directly to + `ConfidentialLLM(auth_headers=...)` / `OhttpRelayClient`, which call it + per request. + """ + return {"Authorization": f"Bearer {self.access_token}"} + + +def login_chat_account( + chat_app_url: str = DEFAULT_CHAT_APP_URL, + *, + app_name: str = "opengradient-sdk", + timeout: float = 300.0, + open_browser: bool = True, + host: str = "127.0.0.1", + port: int = 0, + on_ready: Optional[Callable[[str], None]] = None, +) -> ChatAccountAuth: + """Sign in to an OpenGradient Chat account via the browser and return the session. + + Starts a loopback HTTP listener, opens the chat app's ``/cli-auth`` page + pointed at it, and blocks until the page posts back the session bundle (after + the user signs in and authorizes) or ``timeout`` elapses. + + Args: + chat_app_url: Base URL of the chat app hosting ``/cli-auth``. Defaults to + ``https://chat.opengradient.ai``. + app_name: Name shown to the user on the authorization page. + timeout: Seconds to wait for the user to authorize before giving up. + open_browser: Open the authorization URL in the default browser. When + False (or if no browser is available), the URL is surfaced via + ``on_ready`` / logged so the user can open it manually. + host: Loopback host to bind the listener to (``127.0.0.1`` or + ``localhost`` — the page rejects any non-loopback callback). + port: Loopback port to bind. ``0`` picks a free ephemeral port. + on_ready: Optional callback invoked with the full authorization URL once + the listener is up. Defaults to logging it. Use this to surface the + URL yourself (e.g. print a QR code). + + Returns: + A `ChatAccountAuth` with the session tokens and client config. + + Raises: + TimeoutError: If the user did not authorize within ``timeout`` seconds. + ValueError: If the received bundle was malformed. + """ + server = HTTPServer((host, port), _CallbackHandler) + # Where the browser POSTs the session; single-shot, cleared once received. + server.received_bundle = None # type: ignore[attr-defined] + + bound_port = int(server.server_address[1]) + redirect_uri = f"http://{host}:{bound_port}/" + query = urlencode({"redirect_uri": redirect_uri, "app_name": app_name}) + auth_url = f"{chat_app_url.rstrip('/')}{CLI_AUTH_PATH}?{query}" + + if on_ready is not None: + on_ready(auth_url) + else: + logger.info("Open this URL to authorize the SDK:\n %s", auth_url) + + opened = False + if open_browser: + try: + opened = webbrowser.open(auth_url) + except Exception as exc: # pragma: no cover - platform dependent + logger.debug("Could not open a browser automatically: %s", exc) + if open_browser and not opened: + logger.info("Could not open a browser automatically — open the URL above manually.") + + deadline = time.monotonic() + timeout + try: + while server.received_bundle is None: # type: ignore[attr-defined] + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Timed out after {timeout:.0f}s waiting for chat account authorization.") + # handle_request() blocks up to server.timeout, then returns so we can + # re-check the deadline. A preflight OPTIONS and the POST are separate + # requests, so keep looping until the bundle actually arrives. + server.timeout = remaining + server.handle_request() + finally: + server.server_close() + + return _parse_bundle(server.received_bundle) # type: ignore[attr-defined] + + +def _parse_bundle(bundle: Dict[str, Any]) -> ChatAccountAuth: + """Coerce a received CLI-auth bundle into a `ChatAccountAuth`.""" + if not isinstance(bundle, dict): + raise ValueError("chat auth bundle was not a JSON object") + access_token = bundle.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise ValueError("chat auth bundle is missing an access_token") + user = bundle.get("user") + config = bundle.get("config") + return ChatAccountAuth( + access_token=access_token, + refresh_token=bundle.get("refresh_token") or "", + token_type=bundle.get("token_type") or "bearer", + expires_at=_as_int(bundle.get("expires_at")), + expires_in=_as_int(bundle.get("expires_in")), + user=user if isinstance(user, dict) else {}, + config=config if isinstance(config, dict) else {}, + raw=bundle, + ) + + +def _as_int(value: Any) -> Optional[int]: + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +class _CallbackHandler(BaseHTTPRequestHandler): + """Loopback listener that accepts the session the chat app posts back. + + The page fetches the redirect with ``POST`` + ``Content-Type: application/json``, + which the browser treats as a non-simple cross-origin request — so it first + sends a CORS preflight (``OPTIONS``). Both are answered with permissive CORS + headers; without the preflight response the browser would block the POST and + fall back to manual copy/paste. + """ + + def _send_cors(self) -> None: + origin = self.headers.get("Origin", "*") + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Max-Age", "600") + + def do_OPTIONS(self) -> None: # noqa: N802 - required handler name + self.send_response(204) + self._send_cors() + self.end_headers() + + def do_POST(self) -> None: # noqa: N802 - required handler name + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length > 0 else b"" + try: + data = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + data = None + + if isinstance(data, dict) and data.get("type") == BUNDLE_TYPE: + self.server.received_bundle = data # type: ignore[attr-defined] + self._respond_json(200, b'{"ok":true}') + else: + self._respond_json(400, b'{"ok":false,"error":"unexpected payload"}') + + def _respond_json(self, status: int, body: bytes) -> None: + self.send_response(status) + self._send_cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature + logger.debug("cli-auth callback: " + format, *args) diff --git a/src/opengradient/client/confidential_llm.py b/src/opengradient/client/confidential_llm.py new file mode 100644 index 0000000..257b9ef --- /dev/null +++ b/src/opengradient/client/confidential_llm.py @@ -0,0 +1,296 @@ +"""High-level confidential (Oblivious HTTP) LLM inference. + +This is the one-call entry point for verified, private chat completions over +Oblivious HTTP — the same path the OpenGradient chat app uses in the browser +(``lib/api/ohttp.ts``). It ties together the pieces an integrator would +otherwise have to wire up by hand: + + 1. `opengradient.client.tee_registry` — discover an OHTTP-capable TEE + (endpoint, HPKE key, signing key) from the on-chain registry. + 2. `opengradient.client.tee_ohttp_client` — HPKE-encrypt the request, POST the + ciphertext to the relay's confidential-inference path + (``/api/v1/chat/ohttp``), then decrypt and verify the response. + +Unlike `opengradient.client.llm.LLM`, this client needs **no wallet on the +caller's side**: the request travels end-to-end encrypted to the enclave, and +the untrusted relay (which holds the x402 account and pays per request) only +ever sees ciphertext. Authentication to the relay is left to the caller via an +``auth_headers`` provider, so the client works against any relay deployment +without baking in a credential scheme. + +Every response is signature-verified inside the client before any content is +returned — see `opengradient.client.tee_verify` for the trust chain. + +Usage: + ```python + import opengradient as og + + # Resolve an OHTTP-capable TEE from the on-chain registry and target the + # relay's confidential-inference path automatically. + client = og.ConfidentialLLM( + relay_url="https://chat-api.opengradient.ai", + auth_headers=lambda: {"Authorization": "Bearer "}, + ) + + result = client.chat( + model=og.TEE_LLM.CLAUDE_HAIKU_4_5, + messages=[{"role": "user", "content": "Hello!"}], + max_tokens=200, + ) + print(result.content) # verified assistant text + print(result.proof.tee_id) # attested enclave identity + ``` +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +import requests + +from .tee_ohttp_client import AuthHeaderProvider, OhttpRelayClient, VerifiedChatResponse +from .tee_registry import TEEEndpoint, TEERegistry + +if TYPE_CHECKING: + from ..types import TEE_LLM + +# Registry defaults, kept in sync with `opengradient.client.llm`. Duplicated +# (rather than imported) so the confidential path carries no x402/wallet +# dependency — the relay holds the account, the caller needs nothing on-chain. +DEFAULT_RPC_URL = "https://ogevmdevnet.opengradient.ai" +DEFAULT_TEE_REGISTRY_ADDRESS = "0x703cB174AEadB35D611858369B4b1111dC9Abda6" + +# The relay's confidential-inference path. Mirrors the chat app's +# ``OHTTP_ENDPOINT`` constant (``lib/api/ohttp.ts``): encapsulated OHTTP requests +# are POSTed here. +OHTTP_CHAT_ENDPOINT = "/api/v1/chat/ohttp" + + +class ConfidentialLLM: + """Verified, private chat completions over Oblivious HTTP through a relay. + + Discovers an OHTTP-capable TEE from the on-chain registry, POSTs + HPKE-encrypted requests to the relay's confidential-inference path + (``/api/v1/chat/ohttp``), and verifies the enclave's signature before + returning any content — matching the browser chat app's OHTTP flow. + + Args: + relay_url: The relay base URL (e.g. ``"https://chat-api.opengradient.ai"``) + or the full confidential-inference URL. The + ``/api/v1/chat/ohttp`` path is appended automatically when the given + URL does not already end with it. + rpc_url: RPC endpoint for the chain the TEE registry is deployed on. + Defaults to the OpenGradient devnet. + registry_address: Address of the deployed ``TEERegistry`` contract. + auth_headers: Optional callable returning headers to authenticate to the + relay (called per request so tokens can be refreshed), e.g. + ``lambda: {"Authorization": "Bearer "}``. + session: Optional ``requests.Session`` to reuse connections. + timeout: Per-request timeout in seconds. + + Raises: + RuntimeError: If the registry has no active OHTTP-capable LLM TEE. + """ + + def __init__( + self, + relay_url: str, + *, + rpc_url: str = DEFAULT_RPC_URL, + registry_address: str = DEFAULT_TEE_REGISTRY_ADDRESS, + auth_headers: Optional[AuthHeaderProvider] = None, + session: Optional[requests.Session] = None, + timeout: float = 120.0, + ): + registry = TEERegistry(rpc_url=rpc_url, registry_address=registry_address) + tee = registry.get_llm_tee_ohttp_config() + if tee is None: + raise RuntimeError( + f"No active OHTTP-capable LLM TEE found in the registry (rpc_url={rpc_url}, registry_address={registry_address})." + ) + self._init(relay_url, tee, auth_headers=auth_headers, session=session, timeout=timeout) + + @classmethod + def from_tee( + cls, + relay_url: str, + tee: TEEEndpoint, + *, + auth_headers: Optional[AuthHeaderProvider] = None, + session: Optional[requests.Session] = None, + timeout: float = 120.0, + ) -> "ConfidentialLLM": + """Create a client for a TEE you have already resolved. + + Use this to skip the registry lookup — for a self-hosted TEE, a pinned + enclave, or when you have already selected a `TEEEndpoint` (which must + carry an ``ohttp_config`` and ``signing_public_key_der``). + + Args: + relay_url: The relay base URL or full confidential-inference URL (the + ``/api/v1/chat/ohttp`` path is appended when missing). + tee: The `opengradient.client.tee_registry.TEEEndpoint` to encrypt to. + auth_headers: Optional per-request relay auth header provider. + session: Optional ``requests.Session`` to reuse connections. + timeout: Per-request timeout in seconds. + """ + instance = cls.__new__(cls) + instance._init(relay_url, tee, auth_headers=auth_headers, session=session, timeout=timeout) + return instance + + def _init( + self, + relay_url: str, + tee: TEEEndpoint, + *, + auth_headers: Optional[AuthHeaderProvider], + session: Optional[requests.Session], + timeout: float, + ) -> None: + self._tee = tee + self._relay_url = _confidential_inference_url(relay_url) + self._relay = OhttpRelayClient( + self._relay_url, + tee, + auth_headers=auth_headers, + session=session, + timeout=timeout, + ) + + @property + def tee(self) -> TEEEndpoint: + """The resolved TEE this client encrypts requests to.""" + return self._tee + + @property + def relay_url(self) -> str: + """The full confidential-inference URL requests are POSTed to.""" + return self._relay_url + + # ── Public API ────────────────────────────────────────────────────── + + def chat( + self, + model: Union["TEE_LLM", str], + messages: List[Dict], + max_tokens: int = 100, + stop_sequence: Optional[List[str]] = None, + temperature: float = 0.0, + tools: Optional[List[Dict]] = None, + tool_choice: Optional[str] = None, + response_format: Optional[Dict] = None, + web_search: bool = False, + stream: bool = False, + ) -> VerifiedChatResponse: + """Send a verified, private chat completion over Oblivious HTTP. + + Builds an OpenAI ``/v1/chat/completions`` request from the arguments (the + same shape the chat app builds in ``buildInnerChatRequest``), encrypts + it to the resolved TEE, and returns the signature-verified response. + + Args: + model: The model to use, e.g. ``og.TEE_LLM.CLAUDE_HAIKU_4_5``. A + ``"provider/model"`` value is reduced to the gateway model id + (the part after the ``/``), mirroring the chat app. + messages: OpenAI-style chat messages. + max_tokens: Maximum output tokens. Default 100. + stop_sequence: Optional list of stop sequences. + temperature: Sampling temperature (0-1). Default 0.0. + tools: Optional function-calling tool definitions. + tool_choice: Optional tool-choice directive (forwarded on the wire but + not part of the signed request hash). + response_format: Optional OpenAI ``response_format`` dict. + web_search: Enable the provider's native web search. Default False. + stream: When True, the encrypted stream is fully buffered and verified + before returning; ``result.stream_frames`` holds the decrypted SSE + frames. Default False. + + Returns: + A `opengradient.client.tee_ohttp_client.VerifiedChatResponse`. + + Raises: + opengradient.client.tee_ohttp_client.RelayError: If the relay or inner request errored. + opengradient.client.tee_verify.VerificationError: If verification failed. + """ + body = self._build_body( + model=model, + messages=messages, + max_tokens=max_tokens, + stop_sequence=stop_sequence, + temperature=temperature, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + web_search=web_search, + ) + if stream: + return self._relay.stream_chat_completion(body) + return self._relay.chat_completion(body) + + def chat_completion(self, body: Dict) -> VerifiedChatResponse: + """Send a raw OpenAI chat-completions ``body`` (non-streaming) and verify it. + + Escape hatch for callers that build the request body themselves. See + `chat` for the argument-driven convenience. + """ + return self._relay.chat_completion(body) + + def stream_chat_completion(self, body: Dict) -> VerifiedChatResponse: + """Send a raw OpenAI chat-completions ``body`` as a stream and verify it. + + The encrypted stream is fully buffered and verified before returning, so + ``result.stream_frames`` is safe to replay to an end user. + """ + return self._relay.stream_chat_completion(body) + + @staticmethod + def _build_body( + *, + model: Union["TEE_LLM", str], + messages: List[Dict], + max_tokens: int, + stop_sequence: Optional[List[str]], + temperature: float, + tools: Optional[List[Dict]], + tool_choice: Optional[str], + response_format: Optional[Dict], + web_search: bool, + ) -> Dict: + body: Dict = { + "model": _gateway_model(str(model)), + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + } + if stop_sequence: + body["stop"] = stop_sequence + if tools: + body["tools"] = tools + body["tool_choice"] = tool_choice or "auto" + if response_format: + body["response_format"] = response_format + if web_search: + body["web_search"] = True + return body + + +def _gateway_model(model: str) -> str: + """Reduce a ``"provider/model"`` id to the gateway model name. + + Mirrors the chat app's ``modelToGatewayModel`` (``lib/api/ohttp.ts``): the + gateway expects the model id without the provider prefix. Plain ids without a + ``/`` pass through unchanged. + """ + return model.split("/", 1)[1] if "/" in model else model + + +def _confidential_inference_url(relay_url: str) -> str: + """Join the relay base URL with the confidential-inference path. + + A URL that already targets ``/api/v1/chat/ohttp`` is returned unchanged, so + callers can pass either the relay base or the full endpoint. + """ + trimmed = relay_url.rstrip("/") + if trimmed.endswith(OHTTP_CHAT_ENDPOINT): + return trimmed + return trimmed + OHTTP_CHAT_ENDPOINT diff --git a/tests/chat_auth_test.py b/tests/chat_auth_test.py new file mode 100644 index 0000000..9c4db1b --- /dev/null +++ b/tests/chat_auth_test.py @@ -0,0 +1,130 @@ +"""Tests for the browser-based chat-account login (CLI-auth flow). + +Drives ``login_chat_account`` in a background thread and plays the part of the +browser: parses the authorization URL it surfaces, sends the CORS preflight and +the JSON POST to the loopback listener, and asserts the parsed session. +""" + +from __future__ import annotations + +import http.client +import json +import threading +from urllib.parse import parse_qs, urlparse + +import pytest + +from opengradient.client.chat_auth import BUNDLE_TYPE, ChatAccountAuth, _parse_bundle, login_chat_account + + +def _bundle(access_token="tok_abc"): + return { + "type": BUNDLE_TYPE, + "version": 1, + "access_token": access_token, + "refresh_token": "refresh_xyz", + "token_type": "bearer", + "expires_at": 1_700_000_000, + "expires_in": 3600, + "user": {"id": "u1", "email": "dev@example.com", "is_anonymous": False}, + "config": { + "app_env": "production", + "chat_api_base_url": "https://chat-api.opengradient.ai", + "tee_registry_rpc_url": "https://rpc.example", + "tee_registry_address": "0xabc", + "tee_registry_tee_type": 0, + }, + } + + +def test_parse_bundle_maps_fields_and_helpers(): + auth = _parse_bundle(_bundle()) + assert auth.access_token == "tok_abc" + assert auth.email == "dev@example.com" + assert auth.chat_api_base_url == "https://chat-api.opengradient.ai" + assert auth.tee_registry_rpc_url == "https://rpc.example" + assert auth.tee_registry_address == "0xabc" + assert auth.auth_headers() == {"Authorization": "Bearer tok_abc"} + + +def test_parse_bundle_requires_access_token(): + bad = _bundle() + del bad["access_token"] + with pytest.raises(ValueError): + _parse_bundle(bad) + + +def test_is_expired(): + assert _parse_bundle({**_bundle(), "expires_at": 1}).is_expired is True + # No expiry known -> not treated as expired. + assert _parse_bundle({**_bundle(), "expires_at": None}).is_expired is False + + +def test_login_end_to_end_via_loopback(): + """Full flow: the listener answers the CORS preflight, then accepts the POST.""" + captured_url: dict[str, str] = {} + ready = threading.Event() + result: dict[str, ChatAccountAuth] = {} + error: dict[str, BaseException] = {} + + def on_ready(url: str) -> None: + captured_url["url"] = url + ready.set() + + def run_login() -> None: + try: + result["auth"] = login_chat_account( + chat_app_url="https://chat.example", + open_browser=False, + timeout=10.0, + on_ready=on_ready, + ) + except BaseException as exc: # noqa: BLE001 - surface to the main thread + error["exc"] = exc + + thread = threading.Thread(target=run_login) + thread.start() + + assert ready.wait(timeout=5.0), "login never became ready" + parsed = urlparse(captured_url["url"]) + assert parsed.path == "/cli-auth" + query = parse_qs(parsed.query) + assert query["app_name"] == ["opengradient-sdk"] + redirect_uri = query["redirect_uri"][0] + + target = urlparse(redirect_uri) + assert target.hostname == "127.0.0.1" + + # 1) CORS preflight — the browser sends this before a JSON POST. + preflight = http.client.HTTPConnection(target.hostname, target.port, timeout=5) + preflight.request( + "OPTIONS", + "/", + headers={ + "Origin": "https://chat.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + pre_resp = preflight.getresponse() + pre_resp.read() + assert pre_resp.status == 204 + assert pre_resp.getheader("Access-Control-Allow-Origin") == "https://chat.example" + assert "POST" in (pre_resp.getheader("Access-Control-Allow-Methods") or "") + preflight.close() + + # 2) The actual session POST. + body = json.dumps(_bundle()).encode() + post = http.client.HTTPConnection(target.hostname, target.port, timeout=5) + post.request("POST", "/", body=body, headers={"Content-Type": "application/json", "Origin": "https://chat.example"}) + post_resp = post.getresponse() + payload = post_resp.read() + assert post_resp.status == 200 + assert json.loads(payload)["ok"] is True + post.close() + + thread.join(timeout=5.0) + assert not error, f"login raised: {error.get('exc')}" + auth = result["auth"] + assert auth.access_token == "tok_abc" + assert auth.chat_api_base_url == "https://chat-api.opengradient.ai" diff --git a/tests/confidential_llm_test.py b/tests/confidential_llm_test.py new file mode 100644 index 0000000..dcb4bb8 --- /dev/null +++ b/tests/confidential_llm_test.py @@ -0,0 +1,131 @@ +"""Tests for the high-level ConfidentialLLM (Oblivious HTTP) convenience. + +Exercises the parts ConfidentialLLM adds on top of OhttpRelayClient — building +the request body from arguments, normalizing the gateway model id, and appending +the confidential-inference path — driving a full encrypt/sign/verify round trip +through the self-contained ``recipient`` fixture. +""" + +from __future__ import annotations + +import base64 +import json + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from eth_hash.auto import keccak + +from opengradient.client.confidential_llm import ( + OHTTP_CHAT_ENDPOINT, + ConfidentialLLM, + _confidential_inference_url, + _gateway_model, +) +from opengradient.client.tee_registry import OhttpConfig, TEEEndpoint +from opengradient.client.tee_verify import build_inner_request, tee_id_for_key + + +def _sign_fields(priv, canonical, output_content, ts): + input_hash = keccak(json.dumps(canonical, sort_keys=True).encode()) + output_hash = keccak(output_content.encode()) + msg_hash = keccak(input_hash + output_hash + ts.to_bytes(32, "big")) + sig = priv.sign(msg_hash, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), hashes.SHA256()) + return { + "tee_signature": base64.b64encode(sig).decode(), + "tee_request_hash": input_hash.hex(), + "tee_output_hash": output_hash.hex(), + "tee_timestamp": ts, + } + + +class _FakeResp: + def __init__(self, content=b""): + self.ok = True + self.status_code = 200 + self.content = content + + +def _make_endpoint(recipient): + hpke_priv, hpke_pub = recipient.generate_keypair() + rsa_priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) + der = rsa_priv.public_key().public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo) + pem = ( + rsa_priv.public_key() + .public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) + .decode() + ) + endpoint = TEEEndpoint( + tee_id=tee_id_for_key(pem), + endpoint="https://gw.example", + tls_cert_der=b"", + payment_address="0x" + "11" * 20, + signing_public_key_der=der, + ohttp_config=OhttpConfig(1, 0x0020, 0x0001, 0x0003, hpke_pub, b"kc", 0), + ) + return endpoint, hpke_priv, rsa_priv + + +def _session_with(fake_post): + return type("S", (), {"post": staticmethod(fake_post)})() + + +def test_gateway_model_strips_provider_prefix(): + assert _gateway_model("anthropic/claude-haiku-4-5") == "claude-haiku-4-5" + assert _gateway_model("gpt-4.1") == "gpt-4.1" + + +def test_confidential_inference_url_appends_path_once(): + assert _confidential_inference_url("https://chat-api.example.com/") == "https://chat-api.example.com" + OHTTP_CHAT_ENDPOINT + # A full endpoint URL is left as-is (no double append). + full = "https://relay" + OHTTP_CHAT_ENDPOINT + assert _confidential_inference_url(full) == full + + +def test_from_tee_chat_roundtrip_and_verify(recipient): + endpoint, hpke_priv, rsa_priv = _make_endpoint(recipient) + content = "hello from the enclave" + + # The gateway hashes the model id without the provider prefix, so the + # ConfidentialLLM must have reduced "anthropic/claude-haiku-4-5" to + # "claude-haiku-4-5" for both wire and canonical request to match. + expected_body = { + "model": "claude-haiku-4-5", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 64, + "temperature": 0.0, + } + _wire, canonical = build_inner_request(expected_body) + + def fake_post(url, data=None, headers=None, timeout=None, stream=False): + # The relay base URL had the confidential-inference path appended. + assert url == "https://relay" + OHTTP_CHAT_ENDPOINT + assert headers["X-TEE-ID"] == endpoint.tee_id + assert headers["Authorization"] == "Bearer t0ken" + decap = recipient.decapsulate_request(hpke_priv, data) + sent = json.loads(decap.plaintext.decode()) + assert sent["model"] == "claude-haiku-4-5" + resp = { + "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}], + **_sign_fields(rsa_priv, canonical, content, 1_700_000_000), + "tee_id": endpoint.tee_id, + } + sealed = recipient.encapsulate_response(decap.response_key, decap.enc, json.dumps(resp).encode()) + return _FakeResp(content=sealed) + + client = ConfidentialLLM.from_tee( + "https://relay", + endpoint, + auth_headers=lambda: {"Authorization": "Bearer t0ken"}, + session=_session_with(fake_post), + ) + assert client.relay_url == "https://relay" + OHTTP_CHAT_ENDPOINT + assert client.tee is endpoint + + result = client.chat( + model="anthropic/claude-haiku-4-5", + messages=[{"role": "user", "content": "hi"}], + max_tokens=64, + ) + assert result.content == content + assert result.proof.tee_id == endpoint.tee_id + assert result.proof.timestamp == 1_700_000_000