Add AI Gateway client and text chat hook#343
Conversation
7b66bfa to
c44d4a4
Compare
21bcfc5 to
a98b729
Compare
|
/review |
|
✅ API Design Review completed successfully! |
There was a problem hiding this comment.
Generated by API Design Review for issue #343 · 77.8 AIC · ⌖ 7.18 AIC · ⊞ 5.8K
Comment /review to run again
bcf887f to
4876302
Compare
ba25a8f to
d0ed530
Compare
interacsean
left a comment
There was a problem hiding this comment.
Critical review — two Major findings. Details in inline comments.
| async *chatCompletionStream() { | ||
| throw new Error("AI Gateway not configured"); | ||
| }, | ||
| } satisfies AIGatewayClient; |
There was a problem hiding this comment.
chatCompletionStream doesn't exist on AIGatewayClient — the interface method is streamChatCompletion (packages/core/src/ai/client.ts:34). This is a leftover from the issue's original proposed API name. The satisfies AIGatewayClient here should be failing tsc; it only passes because the e2e package has no type-check/lint script, so the turbo run ... --filter=e2e CI step is a no-op for this package. It's dormant at runtime (the smoke handler early-returns when aiClient is null), but it'd throw streamChatCompletion is not a function if that guard ever changes.
const unavailableAIClient = {
async *streamChatCompletion() {
throw new Error("AI Gateway not configured");
},
} satisfies AIGatewayClient;Separately — worth adding a real type-check script to e2e/package.json so the CI step that claims to type-check this package actually does.
There was a problem hiding this comment.
Fixed in dbd2e53. I renamed the fallback method to streamChatCompletion and added real lint/type-check scripts in e2e/package.json so turbo run lint type-check --filter=e2e is no longer a no-op.
|
|
||
| yield* streamOpenAICompatibleResponse({ | ||
| endpoint, | ||
| authClient: config.authClient, |
There was a problem hiding this comment.
Routing is driven entirely by the caller's request.stream flag here — there's no gemini-* model detection. That diverges from issue #1418 §4 (Provider routing: gemini-* → JSON path, everything else → streamed), and from this PR's own Summary, which says it "normalize[s] gemini-* responses into a single text chunk."
Concrete impact: useAIChat({ model: "gemini-2.5-flash" }) defaults to stream: true → SSE path. If the gateway returns a single JSON body for Gemini (the premise behind the issue's routing rule), the SSE parser yields zero text-deltas and Gemini chat silently returns empty. The Gemini/JSON path is only covered in tests by manually passing stream: false, so this default path is untested.
Two options:
- Implement the model-based routing the issue specifies (
model.startsWith("gemini-")→ JSON path), or - If the explicit
streamflag is the intended replacement for model sniffing, keep it — but update the PR description and docs to state that provider routing is caller-driven and thatuseAIChatwon't auto-select the JSON path for Gemini.
Either way this is a deliberate scope change from the issue and should be called out explicitly rather than described as automatic Gemini handling.
There was a problem hiding this comment.
Fixed in dbd2e53. useAIChat no longer forces stream: true, and the shared client now defaults gemini-* models to the JSON path when stream is unspecified while still honoring explicit stream: true/false overrides. I also added tests for the default Gemini route and the explicit override case.
Motivation
AppShell already has a browser auth stack that handles PKCE, token persistence, refresh, and DPoP-authenticated fetches through
authClient.fetch(...). AI Gateway can accept DPoP-authenticated browser requests, so the missing piece for frontend consumers is not another auth layer but a small transport surface that normalizes AI Gateway responses into something AppShell apps can use directly.This PR implements the first phase of the design discussed in tailor-inc/platform-planning#1418. That phase is intentionally limited to AppShell-owned primitives: a low-level AI Gateway client plus a simple text-only chat hook built on top of it. The optional Vercel AI SDK transport is left for a later phase so core can ship the smallest useful integration first.
Design Decision
Keep the client explicit
useAIChat(...)keeps the client explicit (useAIChat({ client, model })) instead of introducing anAIProviderin this phase.That choice is intentional:
AuthProviderowns shared auth state and lifecycle concerns such as readiness, login/logout, token refresh, and guards, whileAIGatewayClientis currently just a configured transport surface.AIProvidercan still be added later as an additive convenience API if AppShell grows shared AI state, default models/prompts, or more composable chat UI primitives. For now, provider-first would add abstraction without much payoff.Name the hook
useAIChatThe hook is named
useAIChat(...)rather thanuseChat(...)on purpose. Vercel AI SDK can own the genericuseChatname because it is an AI-focused package, but AppShell is not. In AppShell,useChatwould read like a generic chat primitive and overclaim the namespace for something that is specifically a wrapper around AI Gateway.useAIChatmakes that dependency explicit and signals that the hook is for chatting with an AI model, not for arbitrary chat state in the design system.Defer tool use
Tool use is also an important capability, but this PR intentionally keeps it out of scope for now. Instead, this change only leaves room in the type definitions so tool-related APIs can be added later without reshaping the core surface again.
That separation is intentional:
Summary
createAIGatewayClient(...)to call AI Gateway viaauthClient.fetch(...), stream text deltas for OpenAI-compatible models, and normalizegemini-*responses into a single text chunkuseAIChat(...)as a minimal text-only chat hook with local message state, streaming status, error handling, and abort support@tailor-platform/app-shell, document them, and add a changeset for the new feature