diff --git a/CHANGELOG.md b/CHANGELOG.md index 9161612..5c7167b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ here. The release version is defined in the workspace root `package.json`. ## [Unreleased] +## [0.5.1] - 2026-09-21 + +### Added + +- The `grok-4.7` language model (SpaceXAI), served through Vercel AI Gateway + (`VERCEL_MODELS_API_KEY`) or OpenRouter (`OPENROUTER_MODELS_API_KEY`). It + takes over from `grok-4.6` as the featured Grok model; `grok-4.6` stays + available. + +### Fixed + +- A subscriber closing its `POST /api/v1/channel/{channelId}/subscribe` + connection no longer reports an `AbortError: channel stream aborted` to error + tracking. The subscription ends quietly, as the API documents. +- A fetch action whose request starts with blank lines now runs instead of + failing with `cannot parse initial line`, and an empty request returns + `The fetch request is empty.` to the model without making a call. +- A remote MCP server that does not answer in time (`MCP error -32001: Request + timed out`) is no longer reported to error tracking. The model still receives + the timeout error. +- The built-in `clock10` clock no longer starts on Vercel. An instance frozen + between requests dropped the tick mid-publish and reported `TypeError: fetch + failed` to error tracking about 130 times a day. A serverless deployment + keeps the schedule in its queue backend, as before. + ## [0.5.0] - 2026-09-18 ### Added diff --git a/package.json b/package.json index eb1d3b6..b714b71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform", - "version": "0.5.0", + "version": "0.5.1", "private": true, "license": "Apache-2.0", "packageManager": "pnpm@11.24.0", diff --git a/platform/config/models.ts b/platform/config/models.ts index 3703024..88b7aa1 100644 --- a/platform/config/models.ts +++ b/platform/config/models.ts @@ -2237,6 +2237,51 @@ export const openrouterLanguageModels: Record< // xai + 'grok-4.7': { + description: `Grok 4.7 is SpaceXAI's advanced model for coding and professional knowledge work, built to tackle complex, multi-hour tasks with improved self-verification and long-context handling. It strengthens software engineering, document creation, and presentation workflows while maintaining Grok 4.6's speed.`, + + provider: 'openrouter', + + providerModel: 'x-ai/grok-4.7', + + family: 'grok', + + features: ['chat', 'functions', 'image', 'reasoning'], + + region: 'us', + availableRegions: ['us'], + + featured: true, + + maxTokens: 500_000, + maxInputTokens: Math.floor(500_000 * MAX_INPUT_TOKENS_RATIO), + maxOutputTokens: Math.ceil(500_000 * MAX_OUTPUT_TOKENS_RATIO), + + pricing: { + tokenRatio: 0.2667, + inputTokenRatio: 0.1143, + outputTokenRatio: 0.2667, + inputPrice: 1.6, + outputPrice: 4.8, + }, + + interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, + + thresholdStrategy: 'truncate', + + visible: true, + deprecated: false, + + temperature: DEFAULT_TEMPERATURE, + + frequencyPenalty: 0, + presencePenalty: 0, + + tags: [], + + addedDate: '2026-09-21', + }, + 'grok-4.6': { description: `Grok 4.6 builds on Grok 4.5 with a particular focus on long-running agents and more ambitious interactive and visual work. It stays with complex tasks across many steps, whether researching a topic, analyzing information, working across a codebase, or turning an idea into a polished application or work artifact.`, @@ -2251,8 +2296,6 @@ export const openrouterLanguageModels: Record< region: 'us', availableRegions: ['us'], - featured: true, - maxTokens: 500_000, maxInputTokens: Math.floor(500_000 * MAX_INPUT_TOKENS_RATIO), maxOutputTokens: Math.ceil(500_000 * MAX_OUTPUT_TOKENS_RATIO), @@ -5781,6 +5824,63 @@ export const vercelLanguageModels: Record< // xai + 'grok-4.7': { + description: `Grok 4.7 is SpaceXAI's advanced model for coding and professional knowledge work, built to tackle complex, multi-hour tasks with improved self-verification and long-context handling. It strengthens software engineering, document creation, and presentation workflows while maintaining Grok 4.6's speed.`, + + provider: 'vercel', + + providerModel: 'spacexai/grok-4.7', + + providerOptions: { + gateway: { + // @note xai is not a ZDR-compliant provider on the Vercel AI + // Gateway and is the only provider serving this model, so we opt it + // out of the platform's forced-ZDR default. With ZDR on, the gateway + // has no ZDR-compliant provider to route to and the request fails + // with no_providers_available. See the 'vercel gateway config' tests + // in lib/model.provider.vercel.utest.js + zeroDataRetention: false, + }, + }, + + family: 'grok', + + features: ['chat', 'functions', 'image', 'reasoning'], + + region: 'us', + availableRegions: ['us'], + + featured: true, + + maxTokens: 500_000, + maxInputTokens: Math.floor(500_000 * MAX_INPUT_TOKENS_RATIO), + maxOutputTokens: Math.ceil(500_000 * MAX_OUTPUT_TOKENS_RATIO), + + pricing: { + tokenRatio: 0.2, + inputTokenRatio: 0.0857, + outputTokenRatio: 0.2, + inputPrice: 1.2, + outputPrice: 3.6, + }, + + interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, + + thresholdStrategy: 'truncate', + + visible: true, + deprecated: false, + + temperature: DEFAULT_TEMPERATURE, + + frequencyPenalty: 0, + presencePenalty: 0, + + tags: [], + + addedDate: '2026-09-21', + }, + 'grok-4.6': { description: `Grok 4.6 builds on Grok 4.5 with a particular focus on long-running agents and more ambitious interactive and visual work. It stays with complex tasks across many steps, whether researching a topic, analyzing information, working across a codebase, or turning an idea into a polished application or work artifact.`, @@ -5809,8 +5909,6 @@ export const vercelLanguageModels: Record< region: 'us', availableRegions: ['us'], - featured: true, - maxTokens: 500_000, maxInputTokens: Math.floor(500_000 * MAX_INPUT_TOKENS_RATIO), maxOutputTokens: Math.ceil(500_000 * MAX_OUTPUT_TOKENS_RATIO), diff --git a/platform/lib/action.exec.fetch.ts b/platform/lib/action.exec.fetch.ts index 9aacc70..e617c40 100644 --- a/platform/lib/action.exec.fetch.ts +++ b/platform/lib/action.exec.fetch.ts @@ -367,7 +367,8 @@ export function parseRequest(input: string, delim?: string): ParsedRequest { { debug(`parsing request as HTTP`, { input, delim }) - const request = parseHttpRequest(input, delim) as ParsedRequest + // @note a request line cannot start with whitespace, while yaml above depends on it + const request = parseHttpRequest(input.trimStart(), delim) as ParsedRequest return request } @@ -588,6 +589,12 @@ export async function executeFetchAction( 'action.exec.fetch.executeFetchAction' ) + if (input.trim() === '') { + return { + error: 'The fetch request is empty.', + } + } + // @todo run through the zod schema declared above const request = parseRequest(input, '\n') diff --git a/platform/lib/action.exec.fetch.utest.js b/platform/lib/action.exec.fetch.utest.js index f60852a..47d88b8 100644 --- a/platform/lib/action.exec.fetch.utest.js +++ b/platform/lib/action.exec.fetch.utest.js @@ -2531,6 +2531,13 @@ options: expect(result.result).toBeDefined() }) + it('should return an error for a blank request without fetching', async () => { + const result = await executeFetchAction(' \n\n', {}, mockOptions) + + expect(result).toEqual({ error: 'The fetch request is empty.' }) + expect(fetch).not.toHaveBeenCalled() + }) + it('should handle missing optional context values', async () => { getContextContact.mockReturnValue(null) getContextTimezone.mockReturnValue(null) @@ -3437,6 +3444,14 @@ describe('parseRequest', () => { expect(result).toEqual(parseHttpRequest(input)) }) + it('should ignore blank lines before the HTTP request line', () => { + const result = parseRequest('\n\nGET /api/users\nAccept: text/plain', '\n') + + expect(result).toEqual( + parseHttpRequest('GET /api/users\nAccept: text/plain', '\n') + ) + }) + it('should parse http urls as requests', () => { const input = 'http://example.com/api' const result = parseRequest(input) diff --git a/platform/lib/clock.ts b/platform/lib/clock.ts index 4dd8f31..d0459e5 100644 --- a/platform/lib/clock.ts +++ b/platform/lib/clock.ts @@ -14,8 +14,8 @@ // Two limits, both inherited from where this runs. A deployment with several // instances ticks once per instance, and only a queue that deduplicates across // processes collapses them - the barebone one does not, and says so. A -// serverless host ends the interval with the instance, so a deployment there -// needs its queue backend to keep the schedule. +// serverless host freezes the instance between requests, so the clock does not +// start there and the deployment needs its queue backend to keep the schedule. import { TEN_MINUTES_IN_MILLISECONDS } from '@chatbotkit-dev/time' @@ -74,6 +74,14 @@ export async function tick(now: number = Date.now()): Promise { * @returns a function that stops the clock */ export function startClock(): () => void { + // @note a serverless instance is frozen between requests, so a publish + // started by the timer dies mid-connection; the queue backend's own schedule + // is the clock there + + if (process.env.VERCEL) { + return () => {} + } + debug(`clock started`, { interval: CLOCK_INTERVAL }).log('clock.start') const timer = setInterval(() => { diff --git a/platform/lib/clock.utest.js b/platform/lib/clock.utest.js index 6426e3f..ec9fb59 100644 --- a/platform/lib/clock.utest.js +++ b/platform/lib/clock.utest.js @@ -99,6 +99,28 @@ describe('startClock', () => { expect(queue).toHaveBeenCalledTimes(1) }) + + // @note a serverless instance is frozen between requests, so a publish + // started by a timer dies mid-connection and is reported as a failure + it('never ticks on a serverless host', async () => { + const original = process.env.VERCEL + + process.env.VERCEL = '1' + + try { + stop = startClock() + + await jest.advanceTimersByTimeAsync(CLOCK_INTERVAL * 3) + + expect(queue).not.toHaveBeenCalled() + } finally { + if (original === undefined) { + delete process.env.VERCEL + } else { + process.env.VERCEL = original + } + } + }) }) describe('tick', () => { diff --git a/platform/lib/mcp.error.utest.js b/platform/lib/mcp.error.utest.js index adc37a2..dd35273 100644 --- a/platform/lib/mcp.error.utest.js +++ b/platform/lib/mcp.error.utest.js @@ -1,5 +1,6 @@ import { FetchError } from '@/lib/fetch' import { rethrowMcpError } from '@/lib/mcp.error' +import { isUnknownError } from '@/lib/response' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { McpError } from '@modelcontextprotocol/sdk/types.js' @@ -56,6 +57,22 @@ describe('mcp.error', () => { } }) + it('should treat an McpError request timeout as an expected error', () => { + expect.assertions(3) + + const mcpError = new McpError(-32001, 'Request timed out', { + timeout: 60000, + }) + + try { + rethrowMcpError(mcpError) + } catch (e) { + expect(e).toBeInstanceOf(FetchError) + expect(e.code).toBe('-32001') + expect(isUnknownError(e)).toBe(false) + } + }) + it('should not attach meta when McpError has no data', () => { const mcpError = new McpError(-32001, 'Request timed out') diff --git a/platform/lib/response.js b/platform/lib/response.js index 03936fe..209001c 100644 --- a/platform/lib/response.js +++ b/platform/lib/response.js @@ -77,12 +77,13 @@ import { makeJsonSafe } from '@/lib/struct' export * from '@chatbotkit-dev/http-codes' // @note error codes this application treats as expected alongside the HTTP -// ones. They are not HTTP codes: one comes from prisma, the other from the -// channel layer. +// ones. They are not HTTP codes: they come from prisma, the channel layer and +// the MCP client. export const knownExpectedCodesExtra = [ 'P2002', // @note prisma specific for unique constraint violation 'no_message_received_aborted', // @note channel wait timeout - expected behavior when AI takes too long + '-32001', // @note mcp request timeout - the user's remote MCP server did not answer in time ] /** diff --git a/platform/package.json b/platform/package.json index 7a895c3..a30970a 100644 --- a/platform/package.json +++ b/platform/package.json @@ -109,7 +109,7 @@ "start": "next start", "storybook": "storybook dev -p ${STORYBOOK_PORT:-8001}", "studio": "npx prisma studio --port ${STUDIO_PORT:-8002}", - "test": "pnpm test:unit --coverage", + "test": "NODE_OPTIONS=--max-old-space-size=8192 pnpm test:unit --coverage", "test:integration": "NODE_ENV=test SKIP_FUNCTION_CACHE=true SKIP_USAGE_RECORDING=true SKIP_LOG_RECORDING=true jest -c jest.itest.config.js --forceExit", "test:unit": "NODE_ENV=test SKIP_FUNCTION_CACHE=true SKIP_USAGE_RECORDING=true SKIP_LOG_RECORDING=true jest -c jest.utest.config.js --forceExit" }, diff --git a/platform/pages/api/v1/channel/[channelId]/_subscribe.utest.js b/platform/pages/api/v1/channel/[channelId]/_subscribe.utest.js index aab344a..8ce5dff 100644 --- a/platform/pages/api/v1/channel/[channelId]/_subscribe.utest.js +++ b/platform/pages/api/v1/channel/[channelId]/_subscribe.utest.js @@ -90,6 +90,7 @@ describe('bodySchema', () => { describe('POST /api/v1/channel/{channelId}/subscribe', () => { const { streamChannelEvents } = require('@/lib/channel.session') const { throwBadRequest } = require('@/lib/response') + const { AbortError } = require('@/lib/fetch') const mockSession = { id: 'session-abc', user: { id: 'user-456' } } @@ -268,6 +269,67 @@ describe('POST /api/v1/channel/{channelId}/subscribe', () => { }) }) + it('should end quietly when the subscriber closes the connection', async () => { + const channelId = 'valid-channel-id-abcde' + const req = { query: { channelId } } + const abortController = new AbortController() + const stream = { ...makeStream(), abortSignal: abortController.signal } + + streamChannelEvents.mockReturnValue( + (async function* () { + yield { type: 'message', data: { seq: 1 } } + + abortController.abort() + + throw new AbortError('channel stream aborted') + })() + ) + + await expect( + handler(req, stream, mockSession, {}) + ).resolves.toBeUndefined() + + expect(stream.push).toHaveBeenCalledTimes(1) + }) + + it('should rethrow an abort the subscriber did not cause', async () => { + const channelId = 'valid-channel-id-abcde' + const req = { query: { channelId } } + const stream = { + ...makeStream(), + abortSignal: new AbortController().signal, + } + + streamChannelEvents.mockReturnValue( + (async function* () { + throw new AbortError('channel stream aborted') + })() + ) + + await expect(handler(req, stream, mockSession, {})).rejects.toThrow( + 'channel stream aborted' + ) + }) + + it('should rethrow other errors after the subscriber disconnects', async () => { + const channelId = 'valid-channel-id-abcde' + const req = { query: { channelId } } + const abortController = new AbortController() + const stream = { ...makeStream(), abortSignal: abortController.signal } + + streamChannelEvents.mockReturnValue( + (async function* () { + abortController.abort() + + throw new Error('memcache unavailable') + })() + ) + + await expect(handler(req, stream, mockSession, {})).rejects.toThrow( + 'memcache unavailable' + ) + }) + it('should not call stream.push when there are no events', async () => { const channelId = 'valid-channel-id-abcde' const req = { query: { channelId } } diff --git a/platform/pages/api/v1/channel/[channelId]/subscribe.js b/platform/pages/api/v1/channel/[channelId]/subscribe.js index a8cb127..24aae4e 100644 --- a/platform/pages/api/v1/channel/[channelId]/subscribe.js +++ b/platform/pages/api/v1/channel/[channelId]/subscribe.js @@ -1,11 +1,12 @@ // @ts-check import { streamChannelEvents } from '@/lib/channel.session' -import { withStream } from '@/lib/stream' +import { ABORT_ERROR_NAME } from '@/lib/fetch' import schema, { withSchema } from '@/lib/joi.handler' import { withPost } from '@/lib/method' import { requiredUrlParam } from '@/lib/query.get' import { throwBadRequest } from '@/lib/response' import { withSession } from '@/lib/session.handler' +import { withStream } from '@/lib/stream' export const bodySchema = schema.object({ historyLength: schema.number().integer().min(0).max(10000).optional(), @@ -94,18 +95,32 @@ export default withPost( ? { historyLength: body.historyLength } : undefined - for await (const event of streamChannelEvents(session, channelId, { - ...options, + try { + for await (const event of streamChannelEvents(session, channelId, { + ...options, - abortSignal: stream.abortSignal, - })) { - switch (event.type) { - case 'message': { - await stream.push({ type: 'message', data: event.data }) + abortSignal: stream.abortSignal, + })) { + switch (event.type) { + case 'message': { + await stream.push({ type: 'message', data: event.data }) - break + break + } } } + } catch (e) { + // @note the subscriber closing the connection is how a subscription + // normally ends + + if ( + stream.abortSignal?.aborted && + /** @type {Error} */ (e)?.name === ABORT_ERROR_NAME + ) { + return + } + + throw e } }) )