diff --git a/.changeset/model-56-fable.md b/.changeset/model-56-fable.md new file mode 100644 index 00000000..14f3bae6 --- /dev/null +++ b/.changeset/model-56-fable.md @@ -0,0 +1,12 @@ +--- +'@transloadit/mcp-server': patch +'@transloadit/node': patch +'@transloadit/types': patch +'@transloadit/utils': patch +'@transloadit/zod': patch +'transloadit': patch +--- + +Use GPT-5.6 Sol for OpenAI defaults, Claude Fable 5 for general Anthropic defaults, and Claude +Sonnet 5 for image descriptions. Keep end-user Assembly Instructions compilation at medium +reasoning for responsive generation. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 716881d4..1d188c84 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -79,4 +79,4 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | - --model claude-opus-4-8 + --model claude-fable-5 diff --git a/docs/prompts/2026-07-10-model-56-fable-pr.md b/docs/prompts/2026-07-10-model-56-fable-pr.md new file mode 100644 index 00000000..7099a98a --- /dev/null +++ b/docs/prompts/2026-07-10-model-56-fable-pr.md @@ -0,0 +1,21 @@ +# GPT-5.6 Sol and Fable 5 PR follow-up + +PR: https://github.com/transloadit/node-sdk/pull/448 + +## Review context + +- [x] Read the PR description and complete diff. +- [x] Checked comments and reviews; none were open when this checklist was created. +- [x] Confirmed the branch was current with `origin/main` before push. +- [x] Checked the initial GitHub status; the lockfile guard passed and the test matrix was running. + +## Validation + +- [x] Ran three council passes and addressed valid findings. +- [x] Ran `corepack yarn check` successfully across Node, MCP, relay, types, and parity checks. +- [x] Verified the Node AI-chat schema is byte-identical to the canonical content copy. +- [x] No UI behavior changed, so a browser user test is not applicable. + +## Open review items + +- [x] No open review items at checklist creation. diff --git a/packages/node/README.md b/packages/node/README.md index 7e65b1ae..0daaa0aa 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -986,7 +986,7 @@ npx transloadit image describe --input [options] | --- | --- | --- | --- | --- | | `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | -| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514) | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-5) | **Examples** diff --git a/packages/node/docs/intent-commands.md b/packages/node/docs/intent-commands.md index e86a2e78..c8848d15 100644 --- a/packages/node/docs/intent-commands.md +++ b/packages/node/docs/intent-commands.md @@ -854,7 +854,7 @@ npx transloadit image describe --input [options] | --- | --- | --- | --- | --- | | `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | -| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514) | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-5) | **Examples** diff --git a/packages/node/src/alphalib/types/robots/ai-chat.ts b/packages/node/src/alphalib/types/robots/ai-chat.ts index 473fd0c3..0bda246f 100644 --- a/packages/node/src/alphalib/types/robots/ai-chat.ts +++ b/packages/node/src/alphalib/types/robots/ai-chat.ts @@ -9,8 +9,16 @@ import { interpolateRobot, robotBase, robotUse } from './_instructions-primitive // the node-sdk, which does rely on this ai-chat file to determine // support Robot parameters. +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue } + // Define JSONValue schema for proper type matching with AI SDK -const jsonValueSchema: z.ZodType = z.lazy(() => +const jsonValueSchema: z.ZodType = z.lazy(() => z.union([ z.string(), z.number(), @@ -21,74 +29,323 @@ const jsonValueSchema: z.ZodType = z.lazy(() => ]), ) -// Define provider metadata schema to match the AI SDK v5 +// Define provider options schema to match the AI SDK. const providerMetadataSchema = z.record(z.record(jsonValueSchema)).optional() +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function withCurrentProviderOptions(value: Record): Record { + const { experimental_providerMetadata, ...rest } = value + const providerOptions = value.providerOptions ?? experimental_providerMetadata + return providerOptions === undefined ? rest : { ...rest, providerOptions } +} + +function legacyToolOutput( + result: unknown, + isError: boolean, + experimentalContent: unknown, +): unknown { + if (!isError && Array.isArray(experimentalContent)) { + const content = experimentalContent.flatMap((part): unknown[] => { + if (!isRecord(part)) { + return [] + } + if (part.type === 'text' && typeof part.text === 'string') { + return [{ type: 'text', text: part.text }] + } + if (part.type === 'image' && typeof part.data === 'string') { + return [ + { + type: 'image-data', + data: part.data, + mediaType: typeof part.mimeType === 'string' ? part.mimeType : 'image', + }, + ] + } + if ( + part.type === 'media' && + typeof part.data === 'string' && + typeof part.mediaType === 'string' + ) { + return [{ type: 'file-data', data: part.data, mediaType: part.mediaType }] + } + return [] + }) + if (content.length === experimentalContent.length) { + return { type: 'content', value: content } + } + } + + let parsed: ReturnType | undefined + try { + // Zod's recursive JSON schema overflows before returning a failed parse for circular values. + parsed = jsonValueSchema.safeParse(result) + } catch { + parsed = undefined + } + if (parsed?.success) { + return { type: isError ? 'error-json' : 'json', value: parsed.data } + } + return { type: isError ? 'error-text' : 'text', value: stringifyLegacyToolResult(result) } +} + +function stringifyLegacyToolResult(result: unknown): string { + try { + const serialized = JSON.stringify(result) + if (serialized !== undefined) { + return serialized + } + } catch { + // Fall back to the platform string representation for circular or unsupported values. + } + return String(result) +} + +function normalizeMessagePart(value: unknown): unknown { + if (!isRecord(value)) { + return value + } + + const normalized = withCurrentProviderOptions(value) + if (normalized.type === 'image' && !('mediaType' in normalized) && 'mimeType' in normalized) { + const { mimeType, ...rest } = normalized + return { ...rest, mediaType: mimeType } + } + if ( + normalized.type === 'media' && + typeof normalized.data === 'string' && + typeof normalized.mediaType === 'string' + ) { + const { data, mediaType, type: _type, ...rest } = normalized + return { ...rest, type: 'file', data: { type: 'data', data }, mediaType } + } + if (normalized.type === 'tool-call' && !('input' in normalized) && 'args' in normalized) { + const { args, ...rest } = normalized + return { ...rest, input: args } + } + if (normalized.type === 'tool-result' && 'output' in normalized) { + const output = normalized.output + if (isRecord(output) && output.type === 'content' && Array.isArray(output.value)) { + return { + ...normalized, + output: { + ...output, + value: output.value.map((part) => { + if ( + isRecord(part) && + part.type === 'media' && + typeof part.data === 'string' && + typeof part.mediaType === 'string' + ) { + return { type: 'file-data', data: part.data, mediaType: part.mediaType } + } + return part + }), + }, + } + } + } + if (normalized.type === 'tool-result' && !('output' in normalized)) { + const { experimental_content: experimentalContent, isError, result, ...rest } = normalized + return { + ...rest, + output: legacyToolOutput(result, isError === true, experimentalContent), + } + } + return normalized +} + +function normalizeMessage(value: unknown): unknown { + if (!isRecord(value)) { + return value + } + const normalized = withCurrentProviderOptions(value) + return Array.isArray(normalized.content) + ? { ...normalized, content: normalized.content.map(normalizeMessagePart) } + : normalized +} + +const inlineDataSchema = z.union([z.string(), z.instanceof(Uint8Array), z.instanceof(ArrayBuffer)]) +const providerReferenceSchema = z.record(z.string(), z.string()) +const taggedFileDataSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('data'), data: inlineDataSchema }), + z.object({ type: z.literal('url'), url: z.instanceof(URL) }), + z.object({ type: z.literal('reference'), reference: providerReferenceSchema }), + z.object({ type: z.literal('text'), text: z.string() }), +]) +const taggedReasoningFileDataSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('data'), data: inlineDataSchema }), + z.object({ type: z.literal('url'), url: z.instanceof(URL) }), +]) + const textPartSchema = z.object({ type: z.literal('text'), text: z.string(), - experimental_providerMetadata: providerMetadataSchema, + providerOptions: providerMetadataSchema, }) const imagePartSchema = z.object({ type: z.literal('image'), - image: z.union([ - z.string(), - z.instanceof(Uint8Array), - z.instanceof(ArrayBuffer), - // Note: Buffer is not included here since it's Node.js-only and this code runs in browsers. - // Node.js Buffer extends Uint8Array, so Uint8Array validation handles Buffer values too. - z.instanceof(URL), - ]), - mimeType: z.string().optional(), - experimental_providerMetadata: providerMetadataSchema, + image: z.union([inlineDataSchema, z.instanceof(URL), providerReferenceSchema]), + mediaType: z.string().optional(), + providerOptions: providerMetadataSchema, }) const filePartSchema = z.object({ type: z.literal('file'), data: z.union([ - z.string(), - z.instanceof(Uint8Array), - z.instanceof(ArrayBuffer), - // Note: Buffer is not included here since it's Node.js-only and this code runs in browsers. - // Node.js Buffer extends Uint8Array, so Uint8Array validation handles Buffer values too. + taggedFileDataSchema, + inlineDataSchema, z.instanceof(URL), + providerReferenceSchema, ]), + filename: z.string().optional(), + mediaType: z.string(), + providerOptions: providerMetadataSchema, +}) +const reasoningPartSchema = z.object({ + type: z.literal('reasoning'), + text: z.string(), + providerOptions: providerMetadataSchema, +}) +function isCustomKind(value: string): value is `${string}.${string}` { + return value.includes('.') +} +const customPartSchema = z.object({ + type: z.literal('custom'), + kind: z.string().refine(isCustomKind), + providerOptions: providerMetadataSchema, +}) +const reasoningFilePartSchema = z.object({ + type: z.literal('reasoning-file'), + data: z.union([taggedReasoningFileDataSchema, inlineDataSchema, z.instanceof(URL)]), mediaType: z.string(), - experimental_providerMetadata: providerMetadataSchema, + providerOptions: providerMetadataSchema, }) -const toolCallPartSchema = z.object({ +const toolCallPartBaseSchema = z.object({ type: z.literal('tool-call'), toolCallId: z.string(), toolName: z.string(), - args: z.record(jsonValueSchema), - experimental_providerMetadata: providerMetadataSchema, + input: z.unknown(), + providerOptions: providerMetadataSchema, + providerExecuted: z.boolean().optional(), }) -const toolResultPartSchema = z.object({ - type: z.literal('tool-result'), - toolCallId: z.string(), - toolName: z.string(), - result: z.unknown(), - experimental_content: z - .array( +type ToolCallPart = Omit, 'input'> & { input: unknown } +const toolCallPartSchema = toolCallPartBaseSchema.transform( + (part): ToolCallPart => ({ ...part, input: part.input }), +) +const toolOutputSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), value: z.string(), providerOptions: providerMetadataSchema }), + z.object({ + type: z.literal('json'), + value: jsonValueSchema, + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('execution-denied'), + reason: z.string().optional(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('error-text'), + value: z.string(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('error-json'), + value: jsonValueSchema, + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('content'), + value: z.array( z.union([ z.object({ type: z.literal('text'), text: z.string(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('file'), + data: taggedFileDataSchema, + mediaType: z.string(), + filename: z.string().optional(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('file-data'), + data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('file-url'), + url: z.string(), + mediaType: z.string().optional(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('file-id'), + fileId: z.union([z.string(), providerReferenceSchema]), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('file-reference'), + providerReference: providerReferenceSchema, + providerOptions: providerMetadataSchema, }), z.object({ - type: z.literal('image'), + type: z.literal('image-data'), data: z.string(), - mimeType: z.string().optional(), + mediaType: z.string(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('image-url'), + url: z.string(), + providerOptions: providerMetadataSchema, + }), + z.object({ + type: z.literal('image-file-id'), + fileId: z.union([z.string(), providerReferenceSchema]), + providerOptions: providerMetadataSchema, }), + z.object({ + type: z.literal('image-file-reference'), + providerReference: providerReferenceSchema, + providerOptions: providerMetadataSchema, + }), + z.object({ type: z.literal('custom'), providerOptions: providerMetadataSchema }), ]), - ) - .optional(), - isError: z.boolean().optional(), - experimental_providerMetadata: providerMetadataSchema, + ), + }), +]) +const toolResultPartSchema = z.object({ + type: z.literal('tool-result'), + toolCallId: z.string(), + toolName: z.string(), + output: toolOutputSchema, + providerOptions: providerMetadataSchema, +}) +const toolApprovalRequestSchema = z.object({ + type: z.literal('tool-approval-request'), + approvalId: z.string(), + toolCallId: z.string(), + isAutomatic: z.boolean().optional(), + signature: z.string().optional(), +}) +const toolApprovalResponseSchema = z.object({ + type: z.literal('tool-approval-response'), + approvalId: z.string(), + approved: z.boolean(), + reason: z.string().optional(), + providerExecuted: z.boolean().optional(), }) const coreSystemMessageSchema = z.object({ role: z.literal('system'), content: z.string(), - experimental_providerMetadata: providerMetadataSchema, + providerOptions: providerMetadataSchema, }) const coreUserMessageSchema = z.object({ role: z.literal('user'), @@ -96,24 +353,141 @@ const coreUserMessageSchema = z.object({ z.string(), z.array(z.union([textPartSchema, imagePartSchema, filePartSchema])), ]), - experimental_providerMetadata: providerMetadataSchema, + providerOptions: providerMetadataSchema, }) const coreAssistantMessageSchema = z.object({ role: z.literal('assistant'), - content: z.union([z.string(), z.array(z.union([textPartSchema, toolCallPartSchema]))]), - experimental_providerMetadata: providerMetadataSchema, + content: z.union([ + z.string(), + z.array( + z.union([ + textPartSchema, + customPartSchema, + filePartSchema, + reasoningPartSchema, + reasoningFilePartSchema, + toolCallPartSchema, + toolResultPartSchema, + toolApprovalRequestSchema, + ]), + ), + ]), + providerOptions: providerMetadataSchema, }) const coreToolMessageSchema = z.object({ role: z.literal('tool'), - content: z.array(toolResultPartSchema), - experimental_providerMetadata: providerMetadataSchema, + content: z.array(z.union([toolResultPartSchema, toolApprovalResponseSchema])), + providerOptions: providerMetadataSchema, }) -const coreMessageSchema = z.discriminatedUnion('role', [ +const coreMessageOutputSchema = z.discriminatedUnion('role', [ coreSystemMessageSchema, coreUserMessageSchema, coreAssistantMessageSchema, coreToolMessageSchema, ]) +const coreMessageSchema = z.preprocess(normalizeMessage, coreMessageOutputSchema) + +type ProviderMetadata = NonNullable> +type CompatibleProviderOptions = Omit< + Part, + 'providerOptions' +> & { + providerOptions?: ProviderMetadata + experimental_providerMetadata?: ProviderMetadata +} +type MessageProviderOptions = { + providerOptions?: ProviderMetadata + experimental_providerMetadata?: ProviderMetadata +} + +type TextPartInput = CompatibleProviderOptions> +type ImagePartInput = CompatibleProviderOptions> +type LegacyImagePartInput = CompatibleProviderOptions< + Omit, 'mediaType'> & { mimeType?: string } +> +type FilePartInput = CompatibleProviderOptions> +type CustomPartInput = CompatibleProviderOptions> +type ReasoningPartInput = CompatibleProviderOptions> +type ReasoningFilePartInput = CompatibleProviderOptions> +type ToolCallPartInput = CompatibleProviderOptions> +type LegacyToolCallPartInput = Omit & { args: unknown } +type ToolResultPartInput = CompatibleProviderOptions> + +type LegacyTextToolContentPart = { type: 'text'; text: string } +type LegacyImageToolContentPart = { type: 'image'; data: string; mimeType?: string } +type LegacyMediaToolContentPart = { type: 'media'; data: string; mediaType: string } +type LegacyToolContentPart = + | LegacyTextToolContentPart + | LegacyImageToolContentPart + | LegacyMediaToolContentPart +type CurrentToolContentPart = Extract< + z.output, + { type: 'content' } +>['value'][number] +type LegacyToolResultPartInput = { + type: 'tool-result' + toolCallId: string + toolName: string + output?: + | Exclude, { type: 'content' }> + | { type: 'content'; value: Array } + result?: unknown + isError?: boolean + experimental_content?: LegacyToolContentPart[] +} & MessageProviderOptions +type LegacyMediaMessagePartInput = LegacyMediaToolContentPart & MessageProviderOptions + +type CoreSystemMessageInput = { + role: 'system' + content: string +} & MessageProviderOptions +type CoreUserMessageInput = { + role: 'user' + content: + | string + | Array< + | TextPartInput + | ImagePartInput + | LegacyImagePartInput + | FilePartInput + | LegacyMediaMessagePartInput + > +} & MessageProviderOptions +type CoreAssistantMessageInput = { + role: 'assistant' + content: + | string + | Array< + | TextPartInput + | CustomPartInput + | FilePartInput + | ReasoningPartInput + | ReasoningFilePartInput + | ToolCallPartInput + | LegacyToolCallPartInput + | ToolResultPartInput + | LegacyToolResultPartInput + | z.output + > +} & MessageProviderOptions +type CoreToolMessageInput = { + role: 'tool' + content: Array< + ToolResultPartInput | LegacyToolResultPartInput | z.output + > +} & MessageProviderOptions + +export type CoreMessageInput = + | z.output + | CoreSystemMessageInput + | CoreUserMessageInput + | CoreAssistantMessageInput + | CoreToolMessageInput + +type WithTypedMessages = Omit< + Instructions, + 'messages' +> & { messages: string | CoreMessageInput[] } export const meta: RobotMetaInput = { name: 'AiChatRobot', @@ -165,6 +539,8 @@ export const MODEL_CAPABILITIES: Record +export type RobotAiChatInstructionsInput = WithTypedMessages< + z.input +> export type RobotAiChatInstructionsWithHiddenFields = z.infer< typeof robotAiChatInstructionsWithHiddenFieldsSchema > -export type RobotAiChatInstructionsWithHiddenFieldsInput = z.input< - typeof robotAiChatInstructionsWithHiddenFieldsSchema +export type RobotAiChatInstructionsWithHiddenFieldsInput = WithTypedMessages< + z.input > export const interpolatableRobotAiChatInstructionsSchema = interpolateRobot( @@ -298,8 +678,8 @@ export const interpolatableRobotAiChatInstructionsSchema = interpolateRobot( export type InterpolatableRobotAiChatInstructions = z.infer< typeof interpolatableRobotAiChatInstructionsSchema > -export type InterpolatableRobotAiChatInstructionsInput = z.input< - typeof interpolatableRobotAiChatInstructionsSchema +export type InterpolatableRobotAiChatInstructionsInput = WithTypedMessages< + z.input > export const interpolatableRobotAiChatInstructionsWithHiddenFieldsSchema = interpolateRobot( @@ -308,6 +688,6 @@ export const interpolatableRobotAiChatInstructionsWithHiddenFieldsSchema = inter export type InterpolatableRobotAiChatInstructionsWithHiddenFields = z.infer< typeof interpolatableRobotAiChatInstructionsWithHiddenFieldsSchema > -export type InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput = z.input< - typeof interpolatableRobotAiChatInstructionsWithHiddenFieldsSchema +export type InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput = WithTypedMessages< + z.input > diff --git a/packages/node/src/cli/semanticIntents/imageDescribe.ts b/packages/node/src/cli/semanticIntents/imageDescribe.ts index c0262f41..461b9ddb 100644 --- a/packages/node/src/cli/semanticIntents/imageDescribe.ts +++ b/packages/node/src/cli/semanticIntents/imageDescribe.ts @@ -18,7 +18,7 @@ const wordpressDescribeFields = [ 'description', ] as const satisfies readonly ImageDescribeField[] -const defaultDescribeModel = 'anthropic/claude-4-sonnet-20250514' +const defaultDescribeModel = 'anthropic/claude-sonnet-5' const describeFieldDescriptions = { altText: 'A concise accessibility-focused alt text that objectively describes the image', title: 'A concise publishable title for the image', @@ -53,8 +53,7 @@ const imageDescribeExecutionDefinition = { kind: 'string', propertyName: 'model', optionFlags: '--model', - description: - 'Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514)', + description: 'Model to use for generated text fields (default: anthropic/claude-sonnet-5)', required: false, }, ] as const satisfies readonly IntentOptionDefinition[], diff --git a/packages/node/test/unit/ai-chat.test.ts b/packages/node/test/unit/ai-chat.test.ts new file mode 100644 index 00000000..785dd30e --- /dev/null +++ b/packages/node/test/unit/ai-chat.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from 'vitest' + +import { robotAiChatInstructionsSchema } from '../../src/alphalib/types/robots/ai-chat.ts' + +const messagesSchema = robotAiChatInstructionsSchema.shape.messages + +describe('/ai/chat message schema', () => { + it('normalizes persisted AI SDK 5 tool history', () => { + const messages = messagesSchema.parse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'lookup', + args: { query: 'docs' }, + experimental_providerMetadata: { anthropic: { cacheControl: 'ephemeral' } }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'lookup', + result: { found: true }, + }, + ], + }, + ]) + + expect(messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'lookup', + input: { query: 'docs' }, + providerOptions: { anthropic: { cacheControl: 'ephemeral' } }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'lookup', + output: { type: 'json', value: { found: true } }, + }, + ], + }, + ]) + }) + + it('preserves complete AI SDK 7 message histories', () => { + const messages = [ + { + role: 'user', + content: [ + { + type: 'image', + image: 'data:image/png;base64,AA==', + mediaType: 'image/png', + }, + ], + }, + { + role: 'assistant', + content: [ + { + type: 'file', + data: 'data:text/plain;base64,SGk=', + filename: 'answer.txt', + mediaType: 'text/plain', + }, + { type: 'reasoning', text: 'Checked the source.' }, + { + type: 'tool-result', + toolCallId: 'call-2', + toolName: 'lookup', + output: { type: 'json', value: { found: true } }, + }, + { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCallId: 'call-3', + isAutomatic: true, + signature: 'signed-approval', + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: true, + providerExecuted: true, + }, + ], + }, + ] + + expect(messagesSchema.parse(messages)).toEqual(messages) + }) + + it('normalizes persisted AI SDK 5 rich media tool content', () => { + const message = messagesSchema.parse([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'screenshot', + result: 'legacy fallback', + experimental_content: [{ type: 'media', data: 'iVBORw0KGgo=', mediaType: 'image/png' }], + }, + ], + }, + ]) + + expect(message).toEqual([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'screenshot', + output: { + type: 'content', + value: [{ type: 'file-data', data: 'iVBORw0KGgo=', mediaType: 'image/png' }], + }, + }, + ], + }, + ]) + }) + + it('normalizes legacy tool results whose optional result was omitted', () => { + const messages = messagesSchema.parse([ + { + role: 'tool', + content: [{ type: 'tool-result', toolCallId: 'call-1', toolName: 'lookup' }], + }, + ]) + + expect(messages).toMatchObject([ + { + content: [{ output: { type: 'text', value: 'undefined' } }], + }, + ]) + }) + + it('normalizes legacy image tool content without a specific media type', () => { + const messages = messagesSchema.parse([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'screenshot', + experimental_content: [{ type: 'image', data: 'iVBORw0KGgo=' }], + }, + ], + }, + ]) + + expect(messages).toMatchObject([ + { + content: [ + { + output: { + type: 'content', + value: [{ type: 'image-data', data: 'iVBORw0KGgo=', mediaType: 'image' }], + }, + }, + ], + }, + ]) + }) + + it('normalizes legacy media nested in an existing content output', () => { + const messages = messagesSchema.parse([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'screenshot', + output: { + type: 'content', + value: [{ type: 'media', data: 'iVBORw0KGgo=', mediaType: 'image/png' }], + }, + }, + ], + }, + ]) + + expect(messages).toMatchObject([ + { + content: [ + { + output: { + type: 'content', + value: [{ type: 'file-data', data: 'iVBORw0KGgo=', mediaType: 'image/png' }], + }, + }, + ], + }, + ]) + }) + + it('serializes non-JSON legacy tool results before using a lossy string fallback', () => { + const messages = messagesSchema.parse([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'clock', + result: new Date('2026-07-10T00:00:00.000Z'), + }, + ], + }, + ]) + + expect(messages).toMatchObject([ + { + content: [ + { + output: { type: 'text', value: '"2026-07-10T00:00:00.000Z"' }, + }, + ], + }, + ]) + }) + + it('falls back safely when a legacy tool result contains a circular value', () => { + const circularResult: Record = {} + circularResult.self = circularResult + + const messages = messagesSchema.parse([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'circular', + result: circularResult, + }, + ], + }, + ]) + + expect(messages).toMatchObject([ + { + content: [{ output: { type: 'text', value: '[object Object]' } }], + }, + ]) + }) + + it('accepts AI SDK 7 tool-call inputs that are not JSON values', () => { + const input = new Date('2026-07-10T00:00:00.000Z') + + expect( + messagesSchema.parse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'schedule', + input, + }, + ], + }, + ]), + ).toMatchObject([{ content: [{ input }] }]) + }) +}) diff --git a/packages/node/test/unit/assembly-instructions-compiler.test.ts b/packages/node/test/unit/assembly-instructions-compiler.test.ts index ab8cd807..bf002b98 100644 --- a/packages/node/test/unit/assembly-instructions-compiler.test.ts +++ b/packages/node/test/unit/assembly-instructions-compiler.test.ts @@ -68,6 +68,8 @@ describe('compileAssemblyInstructionsFromPrompt', () => { expect(aiSteps[0]).toMatchObject({ robot: '/ai/chat', result: true, + model: 'openai/gpt-5.6-sol', + reasoning_effort: 'medium', format: 'json', interpolate: { system_message: false }, mcp_servers: [ diff --git a/packages/node/test/unit/cli/intents.test.ts b/packages/node/test/unit/cli/intents.test.ts index 525d3f8f..db71dfac 100644 --- a/packages/node/test/unit/cli/intents.test.ts +++ b/packages/node/test/unit/cli/intents.test.ts @@ -391,7 +391,7 @@ describe('intent commands', () => { robot: '/ai/chat', use: ':original', result: true, - model: 'anthropic/claude-4-sonnet-20250514', + model: 'anthropic/claude-sonnet-5', format: 'json', return_messages: 'last', test_credentials: true, diff --git a/packages/transloadit/README.md b/packages/transloadit/README.md index 7e65b1ae..0daaa0aa 100644 --- a/packages/transloadit/README.md +++ b/packages/transloadit/README.md @@ -986,7 +986,7 @@ npx transloadit image describe --input [options] | --- | --- | --- | --- | --- | | `--fields` | `string[]` | no | — | Describe output fields to generate, for example labels or altText,title,caption,description | | `--for` | `string` | no | — | Use a named output profile, currently: wordpress | -| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-4-sonnet-20250514) | +| `--model` | `string` | no | — | Model to use for generated text fields (default: anthropic/claude-sonnet-5) | **Examples** diff --git a/packages/types/scripts/emit-types.test.ts b/packages/types/scripts/emit-types.test.ts index 0d439afe..5b6cf3e8 100644 --- a/packages/types/scripts/emit-types.test.ts +++ b/packages/types/scripts/emit-types.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { escapeStringLiteral, normalizeExportPath } from './emit-types.ts' @@ -47,3 +49,18 @@ for (const { input, expected } of exportCases) { } console.log('emit-types normalizeExportPath: ok') + +// Importing emit-types.ts above runs its top-level generator before this artifact assertion. +const aiChatTypes = readFileSync( + join(import.meta.dirname, '..', 'src', 'generated', 'robots', 'ai-chat.ts'), + 'utf8', +) +assert.match(aiChatTypes, /export type RobotAiChatInstructions/) +assert.match(aiChatTypes, /export type CoreMessageInput/) +assert.match(aiChatTypes, /experimental_providerMetadata\?:/) +assert.match(aiChatTypes, /args: unknown/) +assert.match(aiChatTypes, /kind: `\$\{string\}\.\$\{string\}`/) +assert.doesNotMatch(aiChatTypes, /kind: \{ \[key: number\]: string/) +assert.doesNotMatch(aiChatTypes, /messages: string \| Array/) + +console.log('emit-types recursive JSON schema: ok') diff --git a/packages/types/scripts/emit-types.ts b/packages/types/scripts/emit-types.ts index abc76ba6..b21e057b 100644 --- a/packages/types/scripts/emit-types.ts +++ b/packages/types/scripts/emit-types.ts @@ -86,6 +86,14 @@ const isLibSymbol = (symbol: ts.Symbol | undefined): boolean => const isZodText = (value: string): boolean => value.includes('Zod') || value.includes('objectOutputType') || value.includes('objectInputType') +const isPrivateAliasInSource = (symbol: ts.Symbol, sourceFile: ts.SourceFile): boolean => + symbol.declarations?.some( + (declaration) => + declaration.getSourceFile() === sourceFile && + ts.isTypeAliasDeclaration(declaration) && + !isExported(declaration), + ) === true + const shouldUseTypeToString = (type: ts.Type, checker: ts.TypeChecker): boolean => { if (hasZodType(type, checker, new Set())) { return false @@ -207,19 +215,29 @@ const renderType = ( } if (type.isUnion()) { - const parts = type.types.map((subType) => { - const rendered = renderType(subType, checker, fallbackNode, inProgress) - return wrap(rendered.text, rendered.precedence, TypePrecedence.Union) - }) - return { text: parts.join(' | '), precedence: TypePrecedence.Union } + inProgress.add(type) + try { + const parts = type.types.map((subType) => { + const rendered = renderType(subType, checker, fallbackNode, inProgress) + return wrap(rendered.text, rendered.precedence, TypePrecedence.Union) + }) + return { text: parts.join(' | '), precedence: TypePrecedence.Union } + } finally { + inProgress.delete(type) + } } if (type.isIntersection()) { - const parts = type.types.map((subType) => { - const rendered = renderType(subType, checker, fallbackNode, inProgress) - return wrap(rendered.text, rendered.precedence, TypePrecedence.Intersection) - }) - return { text: parts.join(' & '), precedence: TypePrecedence.Intersection } + inProgress.add(type) + try { + const parts = type.types.map((subType) => { + const rendered = renderType(subType, checker, fallbackNode, inProgress) + return wrap(rendered.text, rendered.precedence, TypePrecedence.Intersection) + }) + return { text: parts.join(' & '), precedence: TypePrecedence.Intersection } + } finally { + inProgress.delete(type) + } } if (type.isLiteral()) { @@ -228,6 +246,12 @@ const renderType = ( } return { text: String(type.value), precedence: TypePrecedence.Primary } } + if (type.flags & ts.TypeFlags.TemplateLiteral) { + return { + text: checker.typeToString(type, undefined, typeFormatFlags), + precedence: TypePrecedence.Primary, + } + } if (type.flags & ts.TypeFlags.BooleanLiteral) { return { text: type.intrinsicName, precedence: TypePrecedence.Primary } } @@ -292,7 +316,12 @@ const renderType = ( const aliasSymbol = type.aliasSymbol const symbol = type.symbol ?? type.aliasSymbol - if (aliasSymbol && !isZodSymbol(aliasSymbol) && shouldUseTypeToString(type, checker)) { + if ( + aliasSymbol && + !isZodSymbol(aliasSymbol) && + !isPrivateAliasInSource(aliasSymbol, fallbackNode.getSourceFile()) && + shouldUseTypeToString(type, checker) + ) { return { text: checker.typeToString(type, undefined, typeFormatFlags), precedence: TypePrecedence.Primary, @@ -313,16 +342,16 @@ const renderType = ( if (properties.length > 0 || stringIndex || numberIndex) { const entries: string[] = [] - if (stringIndex) { - const rendered = renderType(stringIndex, checker, fallbackNode, inProgress) - entries.push(`[key: string]: ${rendered.text}`) - } - if (numberIndex) { - const rendered = renderType(numberIndex, checker, fallbackNode, inProgress) - entries.push(`[key: number]: ${rendered.text}`) - } inProgress.add(type) try { + if (stringIndex) { + const rendered = renderType(stringIndex, checker, fallbackNode, inProgress) + entries.push(`[key: string]: ${rendered.text}`) + } + if (numberIndex) { + const rendered = renderType(numberIndex, checker, fallbackNode, inProgress) + entries.push(`[key: number]: ${rendered.text}`) + } for (const prop of properties) { const propDecl = prop.valueDeclaration ?? prop.declarations?.[0] const propType = checker.getTypeOfSymbolAtLocation(prop, propDecl ?? fallbackNode) diff --git a/packages/utils/src/assemblyInstructionsCompiler.ts b/packages/utils/src/assemblyInstructionsCompiler.ts index 93d92f24..084f0c66 100644 --- a/packages/utils/src/assemblyInstructionsCompiler.ts +++ b/packages/utils/src/assemblyInstructionsCompiler.ts @@ -1,4 +1,4 @@ -export const COMPILE_ASSEMBLY_INSTRUCTIONS_DEFAULT_MODEL = 'openai/gpt-5.5' +export const COMPILE_ASSEMBLY_INSTRUCTIONS_DEFAULT_MODEL = 'openai/gpt-5.6-sol' export const COMPILE_ASSEMBLY_INSTRUCTIONS_DEFAULT_REASONING_EFFORT = 'medium' export const COMPILE_ASSEMBLY_INSTRUCTIONS_DEFAULT_MAX_ATTEMPTS = 3 diff --git a/packages/zod/scripts/sync-v4.ts b/packages/zod/scripts/sync-v4.ts index fa2569ad..9b50efb2 100644 --- a/packages/zod/scripts/sync-v4.ts +++ b/packages/zod/scripts/sync-v4.ts @@ -337,32 +337,11 @@ const patchInterpolatableRobot = (contents: string): string => { } export const patchAiChatSchema = (contents: string): string => { - const jsonValueToken = 'const jsonValueSchema: z.ZodType =' - const jsonValuePatched = 'const jsonValueSchema: z.ZodType =' - const resultToken = 'result: z.unknown(),' - const resultPatched = 'result: z.unknown().optional(),' - - let next = contents - if (next.includes(jsonValueToken)) { - next = next.replace(jsonValueToken, jsonValuePatched) - } - if (next.includes(resultToken)) { - next = next.replace(resultToken, resultPatched) + if (!contents.includes('const jsonValueSchema: z.ZodType =')) { + throw new Error('ai-chat schema patch failed (jsonValueSchema)') } - const hasJsonValue = next.includes(jsonValuePatched) - const hasResult = next.includes(resultPatched) - if (!hasJsonValue || !hasResult) { - const missing = [ - !hasJsonValue ? 'jsonValueSchema' : null, - !hasResult ? 'result optional' : null, - ] - .filter(Boolean) - .join(', ') - throw new Error(`ai-chat schema patch failed (${missing})`) - } - - return next + return contents } const patchFile = (filePath: string, contents: string): string => { diff --git a/packages/zod/test/patch-ai-chat-schema.test.ts b/packages/zod/test/patch-ai-chat-schema.test.ts index 3650158b..03c4c685 100644 --- a/packages/zod/test/patch-ai-chat-schema.test.ts +++ b/packages/zod/test/patch-ai-chat-schema.test.ts @@ -3,34 +3,21 @@ import assert from 'node:assert/strict' import { patchAiChatSchema } from '../scripts/sync-v4.ts' const source = ` -const jsonValueSchema: z.ZodType = - z.union([z.string()]) - -const responseSchema = z.object({ - result: z.unknown(), -}) -` +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } -const patched = patchAiChatSchema(source) -assert.ok( - patched.includes('const jsonValueSchema: z.ZodType ='), - 'should widen jsonValueSchema to ZodType', -) -assert.ok(patched.includes('result: z.unknown().optional(),'), 'should make result optional') - -const alreadyPatched = ` -const jsonValueSchema: z.ZodType = +const jsonValueSchema: z.ZodType = z.union([z.string()]) -const responseSchema = z.object({ - result: z.unknown().optional(), +const toolResultSchema = z.object({ + output: z.unknown(), }) ` +const patched = patchAiChatSchema(source) assert.equal( - patchAiChatSchema(alreadyPatched), - alreadyPatched, - 'should be a no-op when already patched', + patched, + source, + 'should preserve the current AI SDK message schema without v4-only patches', ) assert.throws(() => patchAiChatSchema('const jsonValueSchema: z.ZodType = z.string()'), /ai-chat/i)