Skip to content

Commit 116d2cc

Browse files
authored
Add AI Gateway client and text chat hook (#343)
* Add AI Gateway client and chat hook * Add AI Gateway smoke coverage to e2e * Use auth.name for AI gateway auth namespace * Use gpt-4o-mini * Refine useAIChat sendMessage contract * Refine AI chat streaming API * Refactor AI chat client around completion events * docs: update AI gateway client API reference * Fix AI gateway routing defaults
1 parent 40e0f1c commit 116d2cc

18 files changed

Lines changed: 2949 additions & 160 deletions

.changeset/warm-pans-learn.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
Add a low-level AI Gateway client and a simple text-only chat hook for AppShell.
6+
7+
```tsx
8+
import { createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell";
9+
10+
const aiClient = createAIGatewayClient({ gatewayUri, authClient });
11+
const { messages, sendMessage, stop } = useAIChat({ client: aiClient, model: "gpt-5-mini" });
12+
```

.github/workflows/ci-e2e.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ jobs:
4444
env:
4545
VITE_TAILOR_APP_URL: ${{ secrets.E2E_TAILOR_APP_URL }}
4646
VITE_TAILOR_CLIENT_ID: ${{ secrets.E2E_TAILOR_CLIENT_ID }}
47+
VITE_TAILOR_AI_GATEWAY_URL: ${{ secrets.E2E_TAILOR_AI_GATEWAY_URL }}
4748
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
4849
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
title: createAIGatewayClient
3+
description: Create a low-level AI Gateway client that reuses AppShell authentication
4+
---
5+
6+
# createAIGatewayClient
7+
8+
Creates a small AI Gateway transport client for text-only chat completions.
9+
10+
## Signature
11+
12+
```typescript
13+
function createAIGatewayClient(config: {
14+
gatewayUri: string;
15+
authClient: AuthClient;
16+
}): AIGatewayClient;
17+
```
18+
19+
## Parameters
20+
21+
### `gatewayUri`
22+
23+
- **Type:** `string`
24+
- **Required:** Yes
25+
- **Description:** Base URL of the AI Gateway
26+
27+
### `authClient`
28+
29+
- **Type:** `AuthClient`
30+
- **Required:** Yes
31+
- **Description:** Auth client used for authenticated requests via `authClient.fetch(...)`
32+
33+
## Return Value
34+
35+
```typescript
36+
interface AIGatewayClient {
37+
streamChatCompletion(request: AIGatewayChatRequest): AsyncIterable<AIChatCompletionEvent>;
38+
}
39+
```
40+
41+
The iterable yields completion events:
42+
43+
- `text-delta` — append `event.text` to build the assistant response
44+
- `done` — terminal event with an optional `finishReason`
45+
46+
## Related Types
47+
48+
```typescript
49+
type AIGatewayChatMessage =
50+
| {
51+
role: "system" | "user";
52+
content: string;
53+
}
54+
| {
55+
role: "assistant";
56+
content?: string;
57+
};
58+
59+
interface AIGatewayChatRequest {
60+
model: string;
61+
messages: AIGatewayChatMessage[];
62+
stream?: boolean;
63+
signal?: AbortSignal;
64+
}
65+
66+
type AIChatCompletionEvent =
67+
| {
68+
type: "text-delta";
69+
text: string;
70+
}
71+
| {
72+
type: "done";
73+
finishReason?: string;
74+
};
75+
```
76+
77+
## Usage
78+
79+
```typescript
80+
import { createAuthClient, createAIGatewayClient } from "@tailor-platform/app-shell";
81+
82+
const authClient = createAuthClient({
83+
clientId: "your-client-id",
84+
appUri: "https://xyz.erp.dev",
85+
});
86+
87+
const aiClient = createAIGatewayClient({
88+
gatewayUri: "https://your-ai-gateway.example.com",
89+
authClient,
90+
});
91+
92+
let text = "";
93+
for await (const event of aiClient.streamChatCompletion({
94+
model: "gpt-5-mini",
95+
messages: [{ role: "user", content: "Hello" }],
96+
})) {
97+
if (event.type === "text-delta") {
98+
text += event.text;
99+
}
100+
}
101+
102+
console.log(text);
103+
```
104+
105+
## Notes
106+
107+
- `stream` defaults to `true`
108+
- Pass `stream: false` when the endpoint returns a single JSON response instead of SSE
109+
- `stream: false` still yields the same event shape: zero or one `text-delta`, then `done`
110+
- The low-level API is intentionally narrow: text deltas plus completion metadata
111+
- `request.signal` is passed through so callers can abort in-flight work
112+
113+
## Related
114+
115+
- [Authentication Concept](../concepts/authentication.md)
116+
- [useAIChat](./use-ai-chat.md)

docs/api/use-ai-chat.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
---
2+
title: useAIChat
3+
description: Simple text-only chat hook for AI Gateway
4+
---
5+
6+
# useAIChat
7+
8+
React hook for simple text-only chat on top of `createAIGatewayClient`.
9+
10+
## Signature
11+
12+
```typescript
13+
const useAIChat: (config: { client: AIGatewayClient; model: string; stream?: boolean }) => {
14+
messages: AIChatMessage[];
15+
status: "ready" | "submitted" | "streaming" | "error";
16+
error?: Error;
17+
sendMessage: (message: string) => Promise<boolean>;
18+
stop: () => void;
19+
};
20+
```
21+
22+
## Return Value
23+
24+
### `messages`
25+
26+
```typescript
27+
interface AIChatMessage {
28+
id: string;
29+
role: "user" | "assistant";
30+
content: string;
31+
}
32+
```
33+
34+
### `status`
35+
36+
- **Type:** `"ready" | "submitted" | "streaming" | "error"`
37+
- **Description:** Current request state
38+
39+
### `error`
40+
41+
- **Type:** `Error | undefined`
42+
- **Description:** Last request error, if any
43+
44+
### `sendMessage()`
45+
46+
- **Type:** `(message: string) => Promise<boolean>`
47+
- **Description:** Appends a user message and streams the assistant response
48+
- **Returns:** `true` when the request completes successfully, `false` when the call is ignored, stopped, or fails
49+
50+
### `stop()`
51+
52+
- **Type:** `() => void`
53+
- **Description:** Aborts the current request if one is in progress
54+
55+
## Usage
56+
57+
```tsx
58+
import { createAuthClient, createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell";
59+
60+
const authClient = createAuthClient({
61+
clientId: "your-client-id",
62+
appUri: "https://xyz.erp.dev",
63+
});
64+
65+
const aiClient = createAIGatewayClient({
66+
gatewayUri: "https://your-ai-gateway.example.com",
67+
authClient,
68+
});
69+
70+
export function ChatScreen() {
71+
const { messages, sendMessage, status, stop, error } = useAIChat({
72+
client: aiClient,
73+
model: "gpt-5-mini",
74+
});
75+
76+
return (
77+
<div>
78+
{messages.map((message) => (
79+
<div key={message.id}>
80+
{message.role}: {message.content}
81+
</div>
82+
))}
83+
84+
<button
85+
onClick={() => void sendMessage("Hello")}
86+
disabled={status === "submitted" || status === "streaming"}
87+
>
88+
Send
89+
</button>
90+
<button onClick={stop} disabled={status !== "submitted" && status !== "streaming"}>
91+
Stop
92+
</button>
93+
{error ? <div>{error.message}</div> : null}
94+
</div>
95+
);
96+
}
97+
```
98+
99+
## Notes
100+
101+
- `stream` defaults to `true`
102+
- Pass `stream: false` when the endpoint returns a single JSON response instead of SSE
103+
- The hook is intentionally text-only
104+
- System prompts and custom history shaping should use the low-level client directly
105+
- `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request
106+
107+
## Related
108+
109+
- [createAIGatewayClient](./create-ai-gateway-client.md)

docs/concepts/authentication.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,46 @@ function App() {
154154
}
155155
```
156156

157+
### Using `createAIGatewayClient` with AI Gateway
158+
159+
`createAIGatewayClient` reuses the same authenticated fetch path as the rest of AppShell. Requests go through `authClient.fetch`, so DPoP proof generation and token refresh stay in the auth layer.
160+
161+
```tsx
162+
import { createAuthClient, createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell";
163+
164+
const authClient = createAuthClient({
165+
clientId: "your-client-id",
166+
appUri: "https://xyz.erp.dev",
167+
});
168+
169+
const aiClient = createAIGatewayClient({
170+
gatewayUri: "https://your-ai-gateway.example.com",
171+
authClient,
172+
});
173+
174+
function ChatScreen() {
175+
const { messages, sendMessage, status, stop } = useAIChat({
176+
client: aiClient,
177+
model: "gpt-5-mini",
178+
});
179+
180+
return (
181+
<div>
182+
{messages.map((message) => (
183+
<div key={message.id}>
184+
{message.role}: {message.content}
185+
</div>
186+
))}
187+
188+
<button onClick={() => void sendMessage("Hello")}>Send</button>
189+
<button onClick={stop} disabled={status !== "submitted" && status !== "streaming"}>
190+
Stop
191+
</button>
192+
</div>
193+
);
194+
}
195+
```
196+
157197
### `AuthClientConfig`
158198

159199
| Property | Type | Required | Description |

e2e/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
VITE_TAILOR_APP_URL=
33
# OAuth2 public client ID
44
VITE_TAILOR_CLIENT_ID=
5+
# AI Gateway URL (e.g., https://<gateway-domain>)
6+
VITE_TAILOR_AI_GATEWAY_URL=
57

68
# E2E test user credentials
79
E2E_USER_EMAIL=e2e-test@example.com

e2e/README.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# E2E Tests for AuthProvider
1+
# E2E Tests for AuthProvider and AI Gateway
22

3-
Playwright-based E2E tests that verify the AuthProvider OAuth authentication flow against a real Tailor Platform workspace.
3+
Playwright-based E2E tests that verify the AuthProvider OAuth authentication flow and a minimal AI Gateway smoke check against a real Tailor Platform workspace.
44

55
## Setup
66

@@ -11,7 +11,7 @@ cd e2e/backend
1111
TAILOR_PLATFORM_WORKSPACE_ID=<your-workspace-id> pnpm deploy
1212
```
1313

14-
After deploy, retrieve the app URL and client ID using `tailor-sdk`:
14+
After deploy, retrieve the app URL, client ID, and AI Gateway URL using `tailor-sdk`:
1515

1616
```bash
1717
# Get the app URL
@@ -21,6 +21,10 @@ npx tailor-sdk show --workspace-id <your-workspace-id> --json
2121
# Get the OAuth2 client ID
2222
npx tailor-sdk oauth2client list --workspace-id <your-workspace-id> --json
2323
# → [{"clientId": "tpoc_...", ...}]
24+
25+
# Get the AI Gateway domain
26+
npx tailor-sdk workspace app list --workspace-id <your-workspace-id> --json
27+
# → find the entry named "e2e-ai-gateway" and use https://<domain>
2428
```
2529

2630
### 2. Create a test user
@@ -50,7 +54,7 @@ mutation CreateUserProfile {
5054
cp e2e/.env.example e2e/.env
5155
```
5256

53-
Fill in `VITE_TAILOR_APP_URL` and `VITE_TAILOR_CLIENT_ID` (retrieved above). The test user credentials are pre-filled.
57+
Fill in `VITE_TAILOR_APP_URL`, `VITE_TAILOR_CLIENT_ID`, and `VITE_TAILOR_AI_GATEWAY_URL` (retrieved above). The test user credentials are pre-filled.
5458

5559
### 4. Install dependencies & browsers
5660

@@ -74,12 +78,13 @@ cd e2e && pnpm dev
7478

7579
## Test Scenarios
7680

77-
| Test | Description |
78-
| ------------------- | ---------------------------------------------------------------- |
79-
| Auth guard display | Verifies unauthenticated users see the login UI |
80-
| Login flow | Full OAuth redirect → IDP login → callback → authenticated state |
81-
| Logout | Verifies logout returns to auth guard |
82-
| Session persistence | Confirms page reload maintains authentication |
81+
| Test | Description |
82+
| ------------------- | ------------------------------------------------------------------- |
83+
| Auth guard display | Verifies unauthenticated users see the login UI |
84+
| Login flow | Full OAuth redirect → IDP login → callback → authenticated state |
85+
| Logout | Verifies logout returns to auth guard |
86+
| Session persistence | Confirms page reload maintains authentication |
87+
| AI Gateway smoke | Sends `PING` and checks the OpenAI-compatible reply contains `PONG` |
8388

8489
## Architecture
8590

0 commit comments

Comments
 (0)