diff --git a/package.json b/package.json index 9f599e4b5e..63e46e6b47 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "validate-llms-txt": "node bin/validate-llms.txt.ts" }, "dependencies": { - "@ably/ui": "17.13.2", + "@ably/ui": "17.13.4", "@codesandbox/sandpack-react": "^2.20.0", "@codesandbox/sandpack-themes": "^2.0.21", "@gfx/zopfli": "^1.0.15", diff --git a/src/components/Layout/LanguageSelector.tsx b/src/components/Layout/LanguageSelector.tsx index 00646c0471..666263301d 100644 --- a/src/components/Layout/LanguageSelector.tsx +++ b/src/components/Layout/LanguageSelector.tsx @@ -8,7 +8,7 @@ import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from '@ably/u import { track } from '@ably/ui/core/insights'; import { languageData, languageInfo } from 'src/data/languages'; import { LanguageKey } from 'src/data/languages/types'; -import { useLayoutContext } from 'src/contexts/layout-context'; +import { useLayoutContext, CLIENT_LANGUAGES, AGENT_LANGUAGES } from 'src/contexts/layout-context'; import { navigate } from '../Link'; import { LANGUAGE_SELECTOR_HEIGHT, INKEEP_ASK_BUTTON_HEIGHT } from './utils/heights'; import * as Select from '../ui/Select'; @@ -20,7 +20,7 @@ type LanguageSelectorOptionData = { version: string; }; -export const LanguageSelector = () => { +const SingleLanguageSelector = () => { const { activePage } = useLayoutContext(); const location = useLocation(); const languageVersions = languageData[activePage.product ?? 'pubsub']; @@ -163,3 +163,193 @@ export const LanguageSelector = () => { ); }; + +type DualLanguageDropdownProps = { + label: string; + paramName: 'client_lang' | 'agent_lang'; + languages: LanguageKey[]; + selectedLanguage: LanguageKey | undefined; +}; + +const DualLanguageDropdown = ({ label, paramName, languages, selectedLanguage }: DualLanguageDropdownProps) => { + const { activePage } = useLayoutContext(); + const location = useLocation(); + const languageVersions = languageData[activePage.product ?? 'aiTransport']; + + const options: LanguageSelectorOptionData[] = useMemo( + () => + languages + .filter((lang) => languageVersions[lang]) + .map((lang) => ({ + label: lang, + value: `${lang}-${languageVersions[lang]}`, + version: languageVersions[lang], + })), + [languages, languageVersions], + ); + + const [value, setValue] = useState(''); + + useEffect(() => { + const defaultOption = options.find((option) => option.label === selectedLanguage) || options[0]; + if (defaultOption) { + setValue(defaultOption.value); + } + }, [selectedLanguage, options]); + + const selectedOption = useMemo(() => options.find((option) => option.value === value), [options, value]); + + const handleValueChange = (newValue: string) => { + setValue(newValue); + + const option = options.find((opt) => opt.value === newValue); + if (option) { + track('language_selector_changed', { + language: option.label, + type: paramName, + location: location.pathname, + }); + + // Preserve existing URL params and update the relevant one + const params = new URLSearchParams(location.search); + params.set(paramName, option.label); + navigate(`${location.pathname}?${params.toString()}`); + } + }; + + if (!selectedOption) { + return ; + } + + const selectedLang = languageInfo[selectedOption.label]; + + return ( +
+ + {label} + + + 1 ? 'cursor-pointer' : 'cursor-auto', + )} + style={{ height: LANGUAGE_SELECTOR_HEIGHT }} + aria-label={`Select ${label.toLowerCase()} language`} + disabled={options.length === 1} + > +
+ + {selectedLang?.label} + + v{selectedOption.version} + +
+ {options.length > 1 && ( + + + + )} +
+ + + + +

{label}

+ {options.map((option) => { + const lang = languageInfo[option.label]; + return ( + +
+ + {lang?.label} +
+ + v{option.version} + + {option.value === value ? ( + + + + ) : ( +
+ )} + + ); + })} + + + + +
+ ); +}; + +const DualLanguageSelector = () => { + const { activePage } = useLayoutContext(); + + return ( +
+ + +
+ ); +}; + +// Main export - renders appropriate selector based on page type +export const LanguageSelector = () => { + const { activePage } = useLayoutContext(); + + if (activePage.isDualLanguage) { + return ; + } + + return ; +}; diff --git a/src/components/Layout/LeftSidebar.test.tsx b/src/components/Layout/LeftSidebar.test.tsx index c7efeacfcb..2a2610a411 100644 --- a/src/components/Layout/LeftSidebar.test.tsx +++ b/src/components/Layout/LeftSidebar.test.tsx @@ -16,6 +16,7 @@ jest.mock('src/contexts/layout-context', () => ({ template: null, }, }), + isDualLanguagePath: jest.fn().mockReturnValue(false), })); jest.mock('@reach/router', () => ({ diff --git a/src/components/Layout/LeftSidebar.tsx b/src/components/Layout/LeftSidebar.tsx index 0574666f77..0ad5eedd9b 100644 --- a/src/components/Layout/LeftSidebar.tsx +++ b/src/components/Layout/LeftSidebar.tsx @@ -8,9 +8,36 @@ import Icon from '@ably/ui/core/Icon'; import { productData } from 'src/data'; import { NavProductContent, NavProductPage } from 'src/data/nav/types'; import Link from '../Link'; -import { useLayoutContext } from 'src/contexts/layout-context'; +import { useLayoutContext, isDualLanguagePath } from 'src/contexts/layout-context'; import { interactiveButtonClassName } from './utils/styles'; +// Build link with appropriate language params based on target page type +const buildLinkWithParams = (targetLink: string, searchParams: URLSearchParams): string => { + const clientLang = searchParams.get('client_lang'); + const agentLang = searchParams.get('agent_lang'); + const lang = searchParams.get('lang'); + + const params = new URLSearchParams(); + + if (isDualLanguagePath(targetLink)) { + // Target supports dual language - preserve client_lang/agent_lang + if (clientLang) { + params.set('client_lang', clientLang); + } + if (agentLang) { + params.set('agent_lang', agentLang); + } + } else { + // Target uses single language - preserve lang + if (lang) { + params.set('lang', lang); + } + } + + const paramString = params.toString(); + return paramString ? `${targetLink}?${paramString}` : targetLink; +}; + type LeftSidebarProps = { className?: string; inHeader?: boolean; @@ -78,7 +105,7 @@ const ChildAccordion = ({ content, tree }: { content: (NavProductPage | NavProdu } }, [activePage.tree.length, subtreeIdentifier]); - const lang = new URLSearchParams(location.search).get('lang'); + const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); return ( {page.name} {page.external && ( diff --git a/src/components/Layout/MDXWrapper.tsx b/src/components/Layout/MDXWrapper.tsx index caf5c5468d..dfea0b8fa0 100644 --- a/src/components/Layout/MDXWrapper.tsx +++ b/src/components/Layout/MDXWrapper.tsx @@ -43,8 +43,8 @@ type MDXWrapperProps = PageProps; // Create SDK Context type SDKContextType = { - sdk: SDKType; - setSdk: (sdk: SDKType) => void; + sdk: SDKType | undefined; + setSdk: (sdk: SDKType | undefined) => void; }; type Replacement = { @@ -104,54 +104,95 @@ const WrappedCodeSnippet: React.FC<{ activePage: ActivePage } & CodeSnippetProps return processChild(children); }, [children, replacements]); - // Check if this code block contains only a single utility language - const utilityLanguageOverride = useMemo(() => { + // Detect code block type (client_, agent_, utility, or standard) + const { languageOverride, detectedSdkType } = useMemo(() => { // Utility languages that should be shown without warning (like JSON) - const UTILITY_LANGUAGES = ['html', 'xml', 'css', 'sql', 'json']; + const UTILITY_LANGUAGES = ['html', 'xml', 'css', 'sql', 'json', 'shell', 'text']; - const childrenArray = React.Children.toArray(processedChildren); + // Helper to extract language from className + const extractLangFromClassName = (className: string | undefined): string | null => { + if (!className) { + return null; + } + const langMatch = className.match(/language-(\S+)/); + return langMatch ? langMatch[1] : null; + }; - // Check if this is a single child with a utility language - if (childrenArray.length !== 1) { - return null; - } + // Recursively find all language classes in children + const findLanguages = (node: ReactNode): string[] => { + const languages: string[] = []; + + React.Children.forEach(node, (child) => { + if (!isValidElement(child)) { + return; + } + + const element = child as ReactElement; + const props = element.props || {}; + + // Check className on this element + const lang = extractLangFromClassName(props.className); + if (lang) { + languages.push(lang); + } + + // Recursively check children + if (props.children) { + languages.push(...findLanguages(props.children)); + } + }); - const child = childrenArray[0]; - if (!isValidElement(child)) { - return null; + return languages; + }; + + const languages = findLanguages(processedChildren); + + // Check for client_/agent_ prefixes + const hasClientPrefix = languages.some((lang) => lang.startsWith('client_')); + const hasAgentPrefix = languages.some((lang) => lang.startsWith('agent_')); + + if (hasClientPrefix && activePage.isDualLanguage) { + return { languageOverride: activePage.clientLanguage, detectedSdkType: 'client' as SDKType }; } - const preElement = child as ReactElement; - const codeElement = isValidElement(preElement.props?.children) - ? (preElement.props.children as ReactElement) - : null; + if (hasAgentPrefix && activePage.isDualLanguage) { + return { languageOverride: activePage.agentLanguage, detectedSdkType: 'agent' as SDKType }; + } - if (!codeElement || !codeElement.props.className) { - return null; + // Check for single utility language (existing logic) + if (languages.length === 1 && UTILITY_LANGUAGES.includes(languages[0])) { + return { languageOverride: languages[0], detectedSdkType: undefined }; } - const className = codeElement.props.className as string; - const langMatch = className.match(/language-(\w+)/); - const lang = langMatch ? langMatch[1] : null; + return { languageOverride: undefined, detectedSdkType: undefined }; + }, [processedChildren, activePage.isDualLanguage, activePage.clientLanguage, activePage.agentLanguage]); + + // For client/agent blocks, the page-level selector controls language, so disable internal onChange + const handleLanguageChange = (lang: string, newSdk: SDKType | undefined) => { + // Don't navigate for client/agent blocks - page-level selector handles this + if (detectedSdkType === 'client' || detectedSdkType === 'agent') { + return; + } - // If it's a utility language, return the language to use as override - return lang && UTILITY_LANGUAGES.includes(lang) ? lang : null; - }, [processedChildren]); + if (!detectedSdkType) { + setSdk(newSdk ?? undefined); + } + navigate(`${location.pathname}?lang=${lang}`); + }; return ( { - setSdk(sdk ?? null); - navigate(`${location.pathname}?lang=${lang}`); - }} + lang={languageOverride || activePage.language} + sdk={detectedSdkType || sdk} + onChange={handleLanguageChange} className={cn(props.className, 'mb-5')} languageOrdering={ activePage.product && languageData[activePage.product] ? Object.keys(languageData[activePage.product]) : [] } apiKeys={apiKeys} + // Hide internal language selector for client/agent blocks since page-level selector controls it + fixed={detectedSdkType === 'client' || detectedSdkType === 'agent'} > {processedChildren} @@ -168,11 +209,11 @@ const MDXWrapper: React.FC = ({ children, pageContext, location const { frontmatter } = pageContext; const { activePage } = useLayoutContext(); - const [sdk, setSdk] = useState( + const [sdk, setSdk] = useState( (pageContext.languages ?.filter((language) => language.startsWith('realtime') || language.startsWith('rest')) ?.find((language) => activePage.language && language.endsWith(activePage.language)) - ?.split('_')[0] as SDKType) ?? null, + ?.split('_')[0] as SDKType) ?? undefined, ); const userContext = useContext(UserContext); diff --git a/src/components/Layout/mdx/If.tsx b/src/components/Layout/mdx/If.tsx index ada93c3dee..b1a7700c75 100644 --- a/src/components/Layout/mdx/If.tsx +++ b/src/components/Layout/mdx/If.tsx @@ -5,15 +5,18 @@ import UserContext from 'src/contexts/user-context'; interface IfProps { lang?: LanguageKey; + client_lang?: LanguageKey; + agent_lang?: LanguageKey; + client_or_agent_lang?: LanguageKey; loggedIn?: boolean; className?: string; children: React.ReactNode; as?: React.ElementType; } -const If: React.FC = ({ lang, loggedIn, children }) => { +const If: React.FC = ({ lang, client_lang, agent_lang, client_or_agent_lang, loggedIn, children }) => { const { activePage } = useLayoutContext(); - const { language } = activePage; + const { language, clientLanguage, agentLanguage } = activePage; const userContext = useContext(UserContext); let shouldShow = true; @@ -24,6 +27,26 @@ const If: React.FC = ({ lang, loggedIn, children }) => { shouldShow = shouldShow && splitLang.includes(language); } + // Check client language condition if client_lang prop is provided + if (client_lang !== undefined && clientLanguage) { + const splitLang = client_lang.split(','); + shouldShow = shouldShow && splitLang.includes(clientLanguage); + } + + // Check agent language condition if agent_lang prop is provided + if (agent_lang !== undefined && agentLanguage) { + const splitLang = agent_lang.split(','); + shouldShow = shouldShow && splitLang.includes(agentLanguage); + } + + // Check if either client or agent matches (OR logic) - useful for shared requirements + if (client_or_agent_lang !== undefined) { + const splitLang = client_or_agent_lang.split(','); + const clientMatches = clientLanguage && splitLang.includes(clientLanguage); + const agentMatches = agentLanguage && splitLang.includes(agentLanguage); + shouldShow = shouldShow && (clientMatches || agentMatches); + } + // Check logged in condition if loggedIn prop is provided if (loggedIn !== undefined && userContext.sessionState !== undefined) { const isSignedIn = userContext.sessionState.signedIn ?? false; diff --git a/src/components/Layout/mdx/PageHeader.tsx b/src/components/Layout/mdx/PageHeader.tsx index 19b5eb92f6..9b6c083400 100644 --- a/src/components/Layout/mdx/PageHeader.tsx +++ b/src/components/Layout/mdx/PageHeader.tsx @@ -39,11 +39,14 @@ export const PageHeader: React.FC = ({ title, intro }) => { const showLanguageSelector = useMemo( () => - activePage.languages.length > 0 && - !activePage.languages.every( - (language) => !Object.keys(languageData[product as ProductKey] ?? {}).includes(language), - ), - [activePage.languages, product], + // Always show for dual language pages (AI Transport guides) + activePage.isDualLanguage || + // Standard logic: show if languages exist and at least one is in languageData + (activePage.languages.length > 0 && + !activePage.languages.every( + (language) => !Object.keys(languageData[product as ProductKey] ?? {}).includes(language), + )), + [activePage.languages, product, activePage.isDualLanguage], ); useEffect(() => { diff --git a/src/components/Layout/utils/nav.ts b/src/components/Layout/utils/nav.ts index 3441a1c267..5096fd849b 100644 --- a/src/components/Layout/utils/nav.ts +++ b/src/components/Layout/utils/nav.ts @@ -14,6 +14,10 @@ export type ActivePage = { language: LanguageKey | null; product: ProductKey | null; template: PageTemplate; + // Dual language support for AI Transport guides + clientLanguage?: LanguageKey; + agentLanguage?: LanguageKey; + isDualLanguage?: boolean; }; /** diff --git a/src/contexts/layout-context.tsx b/src/contexts/layout-context.tsx index a81e7a594a..45e3264b00 100644 --- a/src/contexts/layout-context.tsx +++ b/src/contexts/layout-context.tsx @@ -16,6 +16,21 @@ import { ProductKey } from 'src/data/types'; export const DEFAULT_LANGUAGE = 'javascript'; +// Languages supported for dual-language selection in AI Transport guides +export const CLIENT_LANGUAGES: LanguageKey[] = ['javascript', 'swift', 'java']; +export const AGENT_LANGUAGES: LanguageKey[] = ['javascript', 'python', 'java']; + +// Check if a page supports dual language selection based on its path +// Used for navigation param preservation (we don't have access to page content at nav time) +export const isDualLanguagePath = (pathname: string): boolean => { + return pathname.includes('/docs/guides/ai-transport'); +}; + +// Check if page content has client_/agent_ prefixed languages (more accurate than path check) +const hasDualLanguageContent = (languages: string[]): boolean => { + return languages.some((lang) => lang.startsWith('client_') || lang.startsWith('agent_')); +}; + const LayoutContext = createContext<{ activePage: ActivePage; }>({ @@ -26,6 +41,9 @@ const LayoutContext = createContext<{ language: DEFAULT_LANGUAGE, product: null, template: null, + clientLanguage: undefined, + agentLanguage: undefined, + isDualLanguage: false, }, }); @@ -47,6 +65,32 @@ const determineActiveLanguage = ( return DEFAULT_LANGUAGE; }; +// Determine client language for dual-language pages +const determineClientLanguage = (location: string, _product: ProductKey | null): LanguageKey => { + const params = new URLSearchParams(location); + const clientLangParam = params.get('client_lang') as LanguageKey; + + if (clientLangParam && CLIENT_LANGUAGES.includes(clientLangParam)) { + return clientLangParam; + } + + // Default to javascript + return DEFAULT_LANGUAGE; +}; + +// Determine agent language for dual-language pages +const determineAgentLanguage = (location: string, _product: ProductKey | null): LanguageKey => { + const params = new URLSearchParams(location); + const agentLangParam = params.get('agent_lang') as LanguageKey; + + if (agentLangParam && AGENT_LANGUAGES.includes(agentLangParam)) { + return agentLangParam; + } + + // Default to javascript + return DEFAULT_LANGUAGE; +}; + export const LayoutProvider: React.FC> = ({ children, pageContext, @@ -65,6 +109,16 @@ export const LayoutProvider: React.FC @@ -47,13 +47,13 @@ Use the following guides to get started with Anthropic: title: 'Message-per-response', description: 'Stream Anthropic responses using message appends', image: 'icon-tech-javascript', - link: '/docs/guides/ai-transport/anthropic-message-per-response', + link: '/docs/guides/ai-transport/anthropic/anthropic-message-per-response', }, { title: 'Message-per-token', description: 'Stream Anthropic responses using individual token messages', image: 'icon-tech-javascript', - link: '/docs/guides/ai-transport/anthropic-message-per-token', + link: '/docs/guides/ai-transport/anthropic/anthropic-message-per-token', }, ]} @@ -68,13 +68,13 @@ Use the following guides to get started with the Vercel AI SDK: title: 'Message-per-response', description: 'Stream Vercel AI SDK responses using message appends', image: 'icon-tech-javascript', - link: '/docs/guides/ai-transport/vercel-message-per-response', + link: '/docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-response', }, { title: 'Message-per-token', description: 'Stream Vercel AI SDK responses using individual token messages', image: 'icon-tech-javascript', - link: '/docs/guides/ai-transport/vercel-message-per-token', + link: '/docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-token', }, ]} @@ -89,13 +89,13 @@ Use the following guides to get started with LangGraph: title: 'Message-per-response', description: 'Stream LangGraph responses using message appends', image: 'icon-tech-javascript', - link: '/docs/guides/ai-transport/lang-graph-message-per-response', + link: '/docs/guides/ai-transport/langgraph/lang-graph-message-per-response', }, { title: 'Message-per-token', description: 'Stream LangGraph responses using individual token messages', image: 'icon-tech-javascript', - link: '/docs/guides/ai-transport/lang-graph-message-per-token', + link: '/docs/guides/ai-transport/langgraph/lang-graph-message-per-token', }, ]} diff --git a/src/pages/docs/ai-transport/token-streaming/index.mdx b/src/pages/docs/ai-transport/token-streaming/index.mdx index 3de9566ea3..16482d93b4 100644 --- a/src/pages/docs/ai-transport/token-streaming/index.mdx +++ b/src/pages/docs/ai-transport/token-streaming/index.mdx @@ -89,5 +89,5 @@ Different models and frameworks use different events to signal streaming state, - Implement token streaming with [message-per-response](/docs/ai-transport/token-streaming/message-per-response) (recommended for most applications) - Implement token streaming with [message-per-token](/docs/ai-transport/token-streaming/message-per-token) for sliding-window use cases -- Explore the [guides](/docs/guides/ai-transport/openai-message-per-response) for integration with specific models and frameworks +- Explore the [guides](/docs/guides/ai-transport/openai/openai-message-per-response) for integration with specific models and frameworks - Learn about [sessions and identity](/docs/ai-transport/sessions-identity) in AI Transport applications diff --git a/src/pages/docs/guides/ai-transport/anthropic-message-per-token.mdx b/src/pages/docs/guides/ai-transport/anthropic-message-per-token.mdx deleted file mode 100644 index 996c3cb353..0000000000 --- a/src/pages/docs/guides/ai-transport/anthropic-message-per-token.mdx +++ /dev/null @@ -1,363 +0,0 @@ ---- -title: "Guide: Stream Anthropic responses using the message-per-token pattern" -meta_description: "Stream tokens from the Anthropic Messages API over Ably in realtime." -meta_keywords: "AI, token streaming, Anthropic, Claude, Messages API, AI transport, Ably, realtime" ---- - -This guide shows you how to stream AI responses from Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) over Ably using the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token). Specifically, it implements the [explicit start/stop events approach](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which publishes each response token as an individual message, along with explicit lifecycle events to signal when responses begin and end. - -Using Ably to distribute tokens from the Anthropic SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees, ensuring that each client receives the complete response stream with all tokens delivered in order. This approach decouples your AI inference from client connections, enabling you to scale agents independently and handle reconnections gracefully. - - - -## Prerequisites - -To follow this guide, you need: -- Node.js 20 or higher -- An Anthropic API key -- An Ably API key - -Useful links: -- [Anthropic API documentation](https://docs.anthropic.com/en/api) -- [Ably JavaScript SDK getting started](/docs/getting-started/javascript) - -Create a new NPM package, which will contain the publisher and subscriber code: - - -```shell -mkdir ably-anthropic-example && cd ably-anthropic-example -npm init -y -``` - - -Install the required packages using NPM: - - -```shell -npm install @anthropic-ai/sdk@^0.71 ably@^2 -``` - - - - -Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK: - - -```shell -export ANTHROPIC_API_KEY="your_api_key_here" -``` - - -## Step 1: Get a streamed response from Anthropic - -Initialize an Anthropic client and use the [Messages API](https://docs.anthropic.com/en/api/messages) to stream model output as a series of events. - -Create a new file `publisher.mjs` with the following contents: - - -```javascript -import Anthropic from '@anthropic-ai/sdk'; - -// Initialize Anthropic client -const anthropic = new Anthropic(); - -// Process each streaming event -function processEvent(event) { - console.log(JSON.stringify(event)); - // This function is updated in the next sections -} - -// Create streaming response from Anthropic -async function streamAnthropicResponse(prompt) { - const stream = await anthropic.messages.create({ - model: "claude-sonnet-4-5", - max_tokens: 1024, - messages: [{ role: "user", content: prompt }], - stream: true, - }); - - // Iterate through streaming events - for await (const event of stream) { - processEvent(event); - } -} - -// Usage example -streamAnthropicResponse("Tell me a short joke"); -``` - - -### Understand Anthropic streaming events - -Anthropic's Messages API [streams](https://docs.anthropic.com/en/api/messages-streaming) model output as a series of events when you set `stream: true`. Each streamed event includes a `type` property which describes the event type. A complete text response can be constructed from the following event types: - -- [`message_start`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals the start of a response. Contains a `message` object with an `id` to correlate subsequent events. - -- [`content_block_start`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Indicates the start of a new content block. For text responses, the `content_block` will have `type: "text"`; other types may be specified, such as `"thinking"` for internal reasoning tokens. The `index` indicates the position of this item in the message's `content` array. - -- [`content_block_delta`](https://platform.claude.com/docs/en/build-with-claude/streaming#content-block-delta-types): Contains a single text delta in the `delta.text` field. If `delta.type === "text_delta"` the delta contains model response text; other types may be specified, such as `"thinking_delta"` for internal reasoning tokens. Use the `index` to correlate deltas relating to a specific content block. - -- [`content_block_stop`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals completion of a content block. Contains the `index` that identifies content block. - -- [`message_delta`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Contains additional message-level metadata that may be streamed incrementally. Includes a [`delta.stop_reason`](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons) which indicates why the model successfully completed its response generation. - -- [`message_stop`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals the end of the response. - -The following example shows the event sequence received when streaming a response: - - -```json -// 1. Message starts -{"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_016hhjrqVK4rCZ2uEGdyWfmt","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}} - -// 2. Content block starts -{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -// 3. Text tokens stream in as delta events -{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Why"}} -{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" don't scientists trust atoms?\n\nBecause"}} -{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" they make up everything!"}} - -// 4. Content block completes -{"type":"content_block_stop","index":0} - -// 5. Message delta (usage stats) -{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":17}} - -// 6. Message completes -{"type":"message_stop"} -``` - - - - -## Step 2: Publish streaming events to Ably - -Publish Anthropic streaming events to Ably to reliably and scalably distribute them to subscribers. - -This implementation follows the [explicit start/stop events pattern](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which provides clear response boundaries. - -### Initialize the Ably client - -Add the Ably client initialization to your `publisher.mjs` file: - - -```javascript -import Ably from 'ably'; - -// Initialize Ably Realtime client -const realtime = new Ably.Realtime({ - key: '{{API_KEY}}', - echoMessages: false -}); - -// Create a channel for publishing streamed AI responses -const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}'); -``` - - -The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency. - - - -### Map Anthropic streaming events to Ably messages - -Choose how to map [Anthropic streaming events](#understand-streaming-events) to Ably [messages](/docs/messages). You can choose any mapping strategy that suits your application's needs. This guide uses the following pattern as an example: - -- `start`: Signals the beginning of a response -- `token`: Contains the incremental text content for each delta -- `stop`: Signals the completion of a response - - - -Update your `publisher.mjs` file to initialize the Ably client and update the `processEvent()` function to publish events to Ably: - - -```javascript -// Track state across events -let responseId = null; - -// Process each streaming event and publish to Ably -function processEvent(event) { - switch (event.type) { - case 'message_start': - // Capture message ID when response starts - responseId = event.message.id; - - // Publish start event - channel.publish({ - name: 'start', - extras: { - headers: { responseId } - } - }); - break; - - case 'content_block_delta': - // Publish tokens from text deltas only - if (event.delta.type === 'text_delta') { - channel.publish({ - name: 'token', - data: event.delta.text, - extras: { - headers: { responseId } - } - }); - } - break; - - case 'message_stop': - // Publish stop event when response completes - channel.publish({ - name: 'stop', - extras: { - headers: { responseId } - } - }); - break; - } -} -``` - - -This implementation: - -- Publishes a `start` event when the response begins -- Filters for `content_block_delta` events with `text_delta` type and publishes them as `token` events -- Publishes a `stop` event when the response completes -- All published events include the `responseId` in message [`extras`](/docs/messages#properties) to allow the client to correlate events relating to a particular response - - - -Run the publisher to see tokens streaming to Ably: - - -```shell -node publisher.mjs -``` - - -## Step 3: Subscribe to streaming tokens - -Create a subscriber that receives the streaming events from Ably and reconstructs the response. - -Create a new file `subscriber.mjs` with the following contents: - - -```javascript -import Ably from 'ably'; - -// Initialize Ably Realtime client -const realtime = new Ably.Realtime({ key: '{{API_KEY}}' }); - -// Get the same channel used by the publisher -const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}'); - -// Track responses by ID -const responses = new Map(); - -// Handle response start -await channel.subscribe('start', (message) => { - const responseId = message.extras?.headers?.responseId; - console.log('\n[Response started]', responseId); - responses.set(responseId, ''); -}); - -// Handle tokens -await channel.subscribe('token', (message) => { - const responseId = message.extras?.headers?.responseId; - const token = message.data; - - // Append token to response - const currentText = responses.get(responseId) || ''; - responses.set(responseId, currentText + token); - - // Display token as it arrives - process.stdout.write(token); -}); - -// Handle response stop -await channel.subscribe('stop', (message) => { - const responseId = message.extras?.headers?.responseId; - const finalText = responses.get(responseId); - console.log('\n[Response completed]', responseId); -}); - -console.log('Subscriber ready, waiting for tokens...'); -``` - - -Run the subscriber in a separate terminal: - - -```shell -node subscriber.mjs -``` - - -With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the Anthropic model generates them. - -## Step 4: Stream with multiple publishers and subscribers - -Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections. - -### Broadcasting to multiple subscribers - -Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications. - -Run a subscriber in multiple separate terminals: - - -```shell -# Terminal 1 -node subscriber.mjs - -# Terminal 2 -node subscriber.mjs - -# Terminal 3 -node subscriber.mjs -``` - - -All subscribers receive the same stream of tokens in realtime. - -### Publishing concurrent responses - -The implementation uses `responseId` in message [`extras`](/docs/messages#properties) to correlate tokens with their originating response. This enables multiple publishers to stream different responses concurrently on the same [channel](/docs/channels), with each subscriber correctly tracking all responses independently. - -To demonstrate this, run a publisher in multiple separate terminals: - - -```shell -# Terminal 1 -node publisher.mjs - -# Terminal 2 -node publisher.mjs - -# Terminal 3 -node publisher.mjs -``` - - -All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `responseId` to correlate tokens. - -## Next steps - -- Learn more about the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) used in this guide -- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-token#hydration) for handling late joiners and reconnections -- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications -- Explore the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) for storing complete AI responses as single messages in history diff --git a/src/pages/docs/guides/ai-transport/anthropic-message-per-response.mdx b/src/pages/docs/guides/ai-transport/anthropic/anthropic-message-per-response.mdx similarity index 50% rename from src/pages/docs/guides/ai-transport/anthropic-message-per-response.mdx rename to src/pages/docs/guides/ai-transport/anthropic/anthropic-message-per-response.mdx index 663fadf73f..4b1cec6bfc 100644 --- a/src/pages/docs/guides/ai-transport/anthropic-message-per-response.mdx +++ b/src/pages/docs/guides/ai-transport/anthropic/anthropic-message-per-response.mdx @@ -2,6 +2,8 @@ title: "Guide: Stream Anthropic responses using the message-per-response pattern" meta_description: "Stream tokens from the Anthropic Messages API over Ably in realtime using message appends." meta_keywords: "AI, token streaming, Anthropic, Claude, Messages API, AI transport, Ably, realtime, message appends" +redirect_from: + - /docs/guides/ai-transport/anthropic-message-per-response --- This guide shows you how to stream AI responses from Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) over Ably using the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response). Specifically, it appends each response token to a single Ably message, creating a complete AI response that grows incrementally while delivering tokens in realtime. @@ -14,37 +16,92 @@ To discover other approaches to token streaming, including the [message-per-toke ## Prerequisites -To follow this guide, you need: -- Node.js 20 or higher + +The client code requires Node.js 20 or higher. + + +The client code requires Xcode 15 or higher. + + +The client code requires Java 11 or higher. + + + +The agent code requires Node.js 20 or higher. + + +The agent code requires Python 3.8 or higher. + + +The agent code requires Java 11 or higher. + + +You also need: - An Anthropic API key - An Ably API key Useful links: - [Anthropic API documentation](https://docs.anthropic.com/en/api) + - [Ably JavaScript SDK getting started](/docs/getting-started/javascript) - -Create a new NPM package, which will contain the publisher and subscriber code: + + +- [Ably Swift SDK getting started](/docs/getting-started/swift) + + +- [Ably Python SDK getting started](/docs/getting-started/python) + + +- [Ably Java SDK getting started](/docs/getting-started/java) + + +### Agent setup + + +Create a new npm package for the agent (publisher) code: ```shell -mkdir ably-anthropic-example && cd ably-anthropic-example +mkdir ably-anthropic-agent && cd ably-anthropic-agent npm init -y +npm install @anthropic-ai/sdk ably ``` + -Install the required packages using NPM: + +Create a new directory and install the required packages: ```shell -npm install @anthropic-ai/sdk@^0.71 ably@^2 +mkdir ably-anthropic-agent && cd ably-anthropic-agent +pip install anthropic ably ``` + - + +Create a new Maven project and add the following dependencies to your `pom.xml`: + + +```xml + + + com.anthropic + anthropic-java + 1.0.0 + + + io.ably + ably-java + 1.2.46 + + +``` + + -Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK: +Export your Anthropic API key to the environment: ```shell @@ -52,6 +109,58 @@ export ANTHROPIC_API_KEY="your_api_key_here" ``` +### Client setup + + +Create a new npm package for the client (subscriber) code, or use the same project as the agent if both are JavaScript: + + +```shell +mkdir ably-anthropic-client && cd ably-anthropic-client +npm init -y +npm install ably +``` + + + + +Add the Ably SDK to your iOS or macOS project using Swift Package Manager. In Xcode, go to File > Add Package Dependencies and add: + + +```text +https://github.com/ably/ably-cocoa +``` + + +Or add it to your `Package.swift`: + + +```client_swift +dependencies: [ + .package(url: "https://github.com/ably/ably-cocoa", from: "1.2.0") +] +``` + + + + +Add the Ably Java SDK to your `pom.xml`: + + +```xml + + io.ably + ably-java + 1.2.46 + +``` + + + + + ## Step 1: Enable message appends Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [channel rule](/docs/channels#rules) associated with the channel. @@ -79,10 +188,18 @@ The `ai:` namespace is just a naming convention used in this guide. There's noth Initialize an Anthropic client and use the [Messages API](https://docs.anthropic.com/en/api/messages) to stream model output as a series of events. -Create a new file `publisher.mjs` with the following contents: + +In your `ably-anthropic-agent` directory, create a new file `publisher.mjs` with the following contents: + + +In your `ably-anthropic-agent` directory, create a new file `publisher.py` with the following contents: + + +In your agent project, create a new file `Publisher.java` with the following contents: + -```javascript +```agent_javascript import Anthropic from '@anthropic-ai/sdk'; // Initialize Anthropic client @@ -112,6 +229,68 @@ async function streamAnthropicResponse(prompt) { // Usage example streamAnthropicResponse("Tell me a short joke"); ``` + +```agent_python +import asyncio +import anthropic + +# Initialize Anthropic client +client = anthropic.AsyncAnthropic() + +# Process each streaming event +async def process_event(event): + print(event) + # This function is updated in the next sections + +# Create streaming response from Anthropic +async def stream_anthropic_response(prompt: str): + async with client.messages.stream( + model="claude-sonnet-4-5", + max_tokens=1024, + messages=[{"role": "user", "content": prompt}], + ) as stream: + async for event in stream: + await process_event(event) + +# Usage example +asyncio.run(stream_anthropic_response("Tell me a short joke")) +``` + +```agent_java +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.core.http.StreamResponse; +import com.anthropic.models.messages.*; + +public class Publisher { + // Initialize Anthropic client + private static final AnthropicClient client = AnthropicOkHttpClient.fromEnv(); + + // Process each streaming event + private static void processEvent(RawMessageStreamEvent event) { + System.out.println(event); + // This method is updated in the next sections + } + + // Create streaming response from Anthropic + public static void streamAnthropicResponse(String prompt) { + MessageCreateParams params = MessageCreateParams.builder() + .model(Model.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .addUserMessage(prompt) + .build(); + + try (StreamResponse stream = + client.messages().createStreaming(params)) { + stream.stream().forEach(Publisher::processEvent); + } + } + + public static void main(String[] args) { + streamAnthropicResponse("Tell me a short joke"); + } +} +``` ### Understand Anthropic streaming events @@ -168,10 +347,10 @@ Each AI response is stored as a single Ably message that grows as tokens are app ### Initialize the Ably client -Add the Ably client initialization to your `publisher.mjs` file: +Add the Ably client initialization to your publisher file: -```javascript +```agent_javascript import Ably from 'ably'; // Initialize Ably Realtime client @@ -183,6 +362,30 @@ const realtime = new Ably.Realtime({ // Create a channel for publishing streamed AI responses const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}'); ``` + +```agent_python +from ably import AblyRealtime + +# Initialize Ably Realtime client +realtime = AblyRealtime(key='{{API_KEY}}', transport_params={'echo': 'false'}) + +# Create a channel for publishing streamed AI responses +channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}') +``` + +```agent_java +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.types.ClientOptions; + +// Initialize Ably Realtime client +ClientOptions options = new ClientOptions("{{API_KEY}}"); +options.echoMessages = false; +AblyRealtime realtime = new AblyRealtime(options); + +// Create a channel for publishing streamed AI responses +Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); +``` The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency. @@ -199,10 +402,10 @@ When a new response begins, publish an initial message to create it. Ably assign This implementation assumes each response contains a single text content block. It filters out thinking tokens and other non-text content blocks. For production use cases with multiple content blocks or concurrent responses, consider tracking state per message ID and content block index. -Update your `publisher.mjs` file to publish the initial message and append tokens: +Update your publisher file to publish the initial message and append tokens: -```javascript +```agent_javascript // Track state across events let msgSerial = null; let textBlockIndex = null; @@ -244,6 +447,87 @@ async function processEvent(event) { } } ``` + +```agent_python +from ably.types.message import Message + +# Track state across events +msg_serial = None +text_block_index = None + +# Process each streaming event and publish to Ably +async def process_event(event): + global msg_serial, text_block_index + + if event.type == 'message_start': + # Publish initial empty message when response starts + result = await channel.publish('response', '') + + # Capture the message serial for appending tokens + msg_serial = result.serials[0] + + elif event.type == 'content_block_start': + # Capture text block index when a text content block is added + if event.content_block.type == 'text': + text_block_index = event.index + + elif event.type == 'content_block_delta': + # Append tokens from text deltas only + if (event.index == text_block_index and + hasattr(event.delta, 'text') and + msg_serial): + await channel.append_message( + Message(serial=msg_serial, data=event.delta.text) + ) + + elif event.type == 'message_stop': + print('Stream completed!') +``` + +```agent_java +// Track state across events +private static String msgSerial = null; +private static Long textBlockIndex = null; + +// Process each streaming event and publish to Ably +private static void processEvent(RawMessageStreamEvent event) throws AblyException { + if (event.isMessageStart()) { + // Publish initial empty message when response starts + io.ably.lib.types.Message message = new io.ably.lib.types.Message("response", ""); + CompletionListener listener = new CompletionListener() { + @Override + public void onSuccess() {} + @Override + public void onError(ErrorInfo reason) {} + }; + channel.publish(message, listener); + + // Capture the message serial for appending tokens + // Note: In production, use the callback to get the serial + msgSerial = message.serial; + + } else if (event.isContentBlockStart()) { + // Capture text block index when a text content block is added + ContentBlockStartEvent blockStart = event.asContentBlockStart(); + if (blockStart.contentBlock().isText()) { + textBlockIndex = blockStart.index(); + } + + } else if (event.isContentBlockDelta()) { + // Append tokens from text deltas only + ContentBlockDeltaEvent delta = event.asContentBlockDelta(); + if (delta.index().equals(textBlockIndex) && + delta.delta().isTextDelta() && + msgSerial != null) { + String text = delta.delta().asTextDelta().text(); + channel.appendMessage(msgSerial, text); + } + + } else if (event.isMessageStop()) { + System.out.println("Stream completed!"); + } +} +``` This implementation: @@ -252,9 +536,11 @@ This implementation: - Filters for `content_block_delta` events with `text_delta` type from text content blocks - Appends each token to the original message + +