diff --git a/.changeset/provider-skills.md b/.changeset/provider-skills.md new file mode 100644 index 000000000..808690f18 --- /dev/null +++ b/.changeset/provider-skills.md @@ -0,0 +1,20 @@ +--- +'@tanstack/ai-anthropic': minor +'@tanstack/ai-openai': minor +'@tanstack/openai-base': minor +'@tanstack/ai-grok': patch +'@tanstack/ai-groq': patch +--- + +feat: attach hosted provider Skills to code-execution / shell tools + +Hosted, provider-managed Agent Skills can now be attached to the server-side execution tool that runs them: + +- **Anthropic** — `codeExecutionTool(config, { skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }] })`. The adapter lifts the skills into the request's top-level `container.skills` and automatically attaches the required beta headers (`code-execution-2025-08-25` — or `code-execution-2025-05-22` for the legacy `code_execution_20250522` variant — plus `skills-2025-10-02`). +- **OpenAI** — `shellTool({ environment: { type: 'container_auto', skills: [{ type: 'skill_reference', skill_id: '...', version: '2' }] } })`, threaded through the Responses API shell tool. + +Scope: hosted/managed skills referenced by id + version. Skills are inert without the execution tool that runs them. + +Setting skills via Anthropic's `modelOptions.container.skills` is deprecated in favor of `codeExecutionTool(config, { skills })`. + +Bumps the `openai` SDK to `^6.41.0` (required for the typed shell `environment.skills` surface). diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 0fe7bdd74..e13d3fcc0 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -299,6 +299,40 @@ const stream = chat({ **Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +#### Attaching hosted skills + +Pass a `skills` array as the second argument to load provider-managed skill +bundles into the sandbox. The adapter auto-lifts them into the API's +`container.skills` param and adds the required beta headers for you. + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { codeExecutionTool } from "@tanstack/ai-anthropic/tools"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: anthropicText("claude-sonnet-4-5"), + messages, + tools: [ + codeExecutionTool( + { type: "code_execution_20250825", name: "code_execution" }, + { + skills: [{ type: "anthropic", skill_id: "pptx", version: "latest" }], + }, + ), + ], + }); + + return toServerSentEventsResponse(stream); +} +``` + +For the full reference — skill shape, constraints, scope, and the OpenAI +equivalent — see [Provider Skills](../tools/provider-skills.md). + ### `computerUseTool` Allows Claude to observe a virtual desktop (screenshots) and interact with it diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md index 5ad088e71..dbf852ca2 100644 --- a/docs/adapters/openai.md +++ b/docs/adapters/openai.md @@ -573,7 +573,8 @@ const stream = chat({ ### `shellTool` A function-style shell tool that exposes shell execution as a structured -function call. Takes no arguments. +function call. Pass an `environment` object to attach container config and +hosted skills. ```typescript import { chat } from "@tanstack/ai"; @@ -587,7 +588,43 @@ const stream = chat({ }); ``` -**Supported models:** GPT-5.x and other agent-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +**Supported models:** GPT-5.x and other agent-capable models. Responses API +only — Chat Completions does not support the shell tool. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +#### Attaching hosted skills + +Pass `environment.skills` to load provider-managed skill bundles into the +shell's container (Responses API only). + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { shellTool } from "@tanstack/ai-openai/tools"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [ + shellTool({ + environment: { + type: "container_auto", + skills: [ + { type: "skill_reference", skill_id: "skill_abc", version: "2" }, + ], + }, + }), + ], + }); + + return toServerSentEventsResponse(stream); +} +``` + +For the full reference — skill shape, `version` string format, and the +Anthropic equivalent — see [Provider Skills](../tools/provider-skills.md). ### `applyPatchTool` diff --git a/docs/config.json b/docs/config.json index fc80a89cb..70844c69d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -63,6 +63,10 @@ "label": "Provider Tools", "to": "tools/provider-tools" }, + { + "label": "Provider Skills", + "to": "tools/provider-skills" + }, { "label": "Tool Architecture", "to": "tools/tool-architecture" diff --git a/docs/tools/provider-skills.md b/docs/tools/provider-skills.md new file mode 100644 index 000000000..64dc7d4ea --- /dev/null +++ b/docs/tools/provider-skills.md @@ -0,0 +1,178 @@ +--- +title: Provider Skills +id: provider-skills +order: 3 +description: "Attach hosted, provider-managed skills to code execution and shell tools in TanStack AI so the model can produce documents, run specialised environments, and more." +keywords: + - tanstack ai + - provider skills + - anthropic skills + - openai skills + - code execution skills + - shell tool skills + - hosted skills + - container skills +--- + +Provider Skills are hosted, provider-managed capability bundles that the model +loads on demand and runs inside the provider's server-side sandbox. You +reference them by a skill ID; the provider handles installation and execution. + +> **Not to be confused with `@tanstack/ai-code-mode-skills`**, which are +> locally-generated TypeScript functions evaluated client-side. Provider Skills +> run entirely on the provider's infrastructure. + +Skills are **inert without an execution tool**. The execution tool activates the +sandbox; skills are additional bundles that run inside it: + +- **Anthropic**: skills attach to `codeExecutionTool` (`@tanstack/ai-anthropic/tools`). +- **OpenAI**: skills nest inside `shellTool` (`@tanstack/ai-openai/tools`) and + require the Responses API. + +You already have a `chat()` call working. By the end of this page you will have +attached a hosted skill to the right execution tool, with the provider handling +the rest. + +--- + +## Anthropic: skills via `codeExecutionTool` + +### 1. Install the package + +```bash +npm install @tanstack/ai-anthropic +``` + +### 2. Add the `codeExecutionTool` with skills + +Import `codeExecutionTool` from `@tanstack/ai-anthropic/tools`, not from the +adapter root. Pass a `skills` array as the second argument. + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { codeExecutionTool } from '@tanstack/ai-anthropic/tools' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + tools: [ + codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { + skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }], + }, + ), + ], + }) + + return toServerSentEventsResponse(stream) +} +``` + +The adapter automatically: + +- Lifts your skills into the request's top-level `container.skills` parameter + (the shape Anthropic's API requires). +- Attaches the `code-execution-2025-08-25` beta header, plus the + `skills-2025-10-02` beta header when skills are present. + +You do not set beta headers manually. + +### Skill shape + +Each entry in the `skills` array is an `AnthropicContainerSkill`: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `type` | `'anthropic' \| 'custom'` | yes | `'anthropic'` for Anthropic-hosted skills; `'custom'` for your own bundles. | +| `skill_id` | `string` | yes | 1–64 characters. | +| `version` | `string` | no | Specific version string, or `'latest'` (default when omitted). | + +Up to 8 skills per request. The factory throws at call time if you exceed this +or supply an invalid `skill_id`. + +### Deprecation notice + +Setting skills via `modelOptions.container.skills` is deprecated. Use +`codeExecutionTool(config, { skills })` instead — the legacy path bypasses the +automatic beta-header wiring. + +--- + +## OpenAI: skills via `shellTool` (Responses API only) + +The OpenAI `shellTool` accepts an `environment` object that can carry a +`skills` array. This is **Responses API only**; the Chat Completions API does +not support the shell tool. + +### 1. Install the package + +```bash +npm install @tanstack/ai-openai +``` + +### 2. Add the `shellTool` with skills + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { shellTool } from '@tanstack/ai-openai/tools' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.2'), + messages, + tools: [ + shellTool({ + environment: { + type: 'container_auto', + skills: [ + { type: 'skill_reference', skill_id: 'skill_abc', version: '2' }, + ], + }, + }), + ], + }) + + return toServerSentEventsResponse(stream) +} +``` + +### Skill shape + +Each entry in the `skills` array is a `SkillReference`: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `type` | `'skill_reference'` | yes | Always `'skill_reference'` for OpenAI. | +| `skill_id` | `string` | yes | The skill identifier provided by OpenAI. | +| `version` | `string` | no | A positive integer as a string (e.g. `'2'`) or `'latest'`. | + +Note: `version` is a string, not a number. + +--- + +## Scope + +Only **hosted, managed-by-id** skills are wired by these factories: + +- Anthropic: `type: 'anthropic'` or `type: 'custom'` +- OpenAI: `type: 'skill_reference'` + +Inline bundles, local-path references, and upload-API skill creation are not +handled by `codeExecutionTool` or `shellTool`. + +--- + +## Related pages + +- [Provider Tools](./provider-tools.md) — all native provider tools and the + type-level guard that prevents pairing a tool with an unsupported model. +- [Anthropic adapter → `codeExecutionTool`](../adapters/anthropic.md#codeexecutiontool) +- [OpenAI adapter → `shellTool`](../adapters/openai.md#shelltool) diff --git a/docs/tools/provider-tools.md b/docs/tools/provider-tools.md index 69cda2c2a..5c3d04f57 100644 --- a/docs/tools/provider-tools.md +++ b/docs/tools/provider-tools.md @@ -88,6 +88,15 @@ matrix is maintained alongside `model-meta.ts` and reflected here: For the exact per-model list, open the adapter page or read the model's `supports.tools` array directly from `model-meta.ts`. +## Provider Skills + +Anthropic and OpenAI support hosted, provider-managed skill bundles that run +inside the provider's server-side sandbox. Skills attach to an execution tool +(`codeExecutionTool` for Anthropic, `shellTool` for OpenAI) and are referenced +by ID — the provider handles installation and execution. + +See [Provider Skills](./provider-skills.md) for full setup steps and examples. + ## Migrating from earlier versions If you were using `createWebSearchTool` from `@tanstack/ai-openrouter`, see diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index e943da71b..1d45b25f8 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -2,6 +2,7 @@ import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { convertToolsToProviderFormat } from '../tools/tool-converter' +import { readCodeExecutionConfig, readCodeExecutionSkills } from '../tools' import { validateTextProviderOptions } from '../text/text-provider-options' import { buildAnthropicUsage } from '../usage' import { @@ -36,6 +37,7 @@ import type { import type Anthropic_SDK from '@anthropic-ai/sdk' import type { AnthropicBeta } from '@anthropic-ai/sdk/resources/beta/beta' import type { + AnyTool, ContentPart, Modality, ModelMessage, @@ -55,6 +57,60 @@ import type { } from '../message-types' import type { AnthropicClientConfig } from '../utils' +/** + * Computes the `betas` array for a Messages request. Unions: + * - `interleaved-thinking-2025-05-14` when interleaved thinking is enabled, + * - `code-execution-2025-08-25` when a `code_execution` tool is present, + * - `skills-2025-10-02` when that tool carries skills. + * Returns `undefined` when none apply (so the call site omits `betas`). + */ +export function computeAnthropicBetas( + tools: Array | undefined, + modelOptions: + | { + thinking?: { + type?: 'enabled' | 'disabled' | 'adaptive' + budget_tokens?: number + } + } + | undefined, +): Array | undefined { + const betas = new Set() + + const useInterleavedThinking = + modelOptions?.thinking?.type === 'enabled' && + typeof modelOptions.thinking.budget_tokens === 'number' && + modelOptions.thinking.budget_tokens > 0 + if (useInterleavedThinking) betas.add('interleaved-thinking-2025-05-14') + + // Code-execution beta is version-aware: select from the FIRST code_execution + // tool's config type. + const codeExecTool = tools?.find((t) => t.name === 'code_execution') + if (codeExecTool) { + const cfgType = readCodeExecutionConfig(codeExecTool)?.type + // Each code_execution tool version pairs with a specific beta. Known + // legacy variant maps explicitly; current/future variants (e.g. + // `code_execution_20250825` and later) use the latest `-08-25` beta. + betas.add( + cfgType === 'code_execution_20250522' + ? 'code-execution-2025-05-22' + : 'code-execution-2025-08-25', + ) + } + + // Skills beta: scan ALL code_execution tools so this AGREES with the + // container-lift, which lifts skills from any code_execution tool that + // carries them (not just the first). + const hasSkills = tools?.some( + (t) => + t.name === 'code_execution' && + (readCodeExecutionSkills(t)?.length ?? 0) > 0, + ) + if (hasSkills) betas.add('skills-2025-10-02') + + return betas.size > 0 ? Array.from(betas) : undefined +} + /** * Configuration for Anthropic text adapter */ @@ -144,18 +200,8 @@ export class AnthropicTextAdapter< ) // `betas` is attached at the call site rather than in the shared mapper - // because the `interleaved-thinking-2025-05-14` header is only useful for - // the streaming path. - const modelOptions = options.modelOptions as - | InternalTextProviderOptions - | undefined - const useInterleavedThinking = - modelOptions?.thinking?.type === 'enabled' && - typeof modelOptions.thinking.budget_tokens === 'number' && - modelOptions.thinking.budget_tokens > 0 - const betas: Array | undefined = useInterleavedThinking - ? ['interleaved-thinking-2025-05-14'] - : undefined + // because the beta set depends on both the tools and the modelOptions. + const betas = computeAnthropicBetas(options.tools, options.modelOptions) // `client.beta.messages` is Anthropic's permanent staging surface, not a // sunset path: it's a superset of `client.messages` that additionally @@ -237,6 +283,10 @@ export class AnthropicTextAdapter< `activity=chat provider=anthropic model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`, { provider: 'anthropic', model: this.model }, ) + const betas = computeAnthropicBetas( + chatOptions.tools, + chatOptions.modelOptions, + ) // Make non-streaming request with tool_choice forced to our structured output tool const response = await this.client.beta.messages.create( { @@ -244,6 +294,7 @@ export class AnthropicTextAdapter< stream: false, tools: [structuredOutputTool], tool_choice: { type: 'tool', name: 'structured_output' }, + ...(betas && { betas }), }, { signal: chatOptions.request?.signal, @@ -414,6 +465,26 @@ export class AnthropicTextAdapter< } : undefined + // Lift skills attached to a `code_execution` tool into the top-level + // `container.skills` request param (Anthropic's required shape). Preserve any + // `container.id` supplied via modelOptions for container reuse. This is the + // canonical path for skills; `modelOptions.container.skills` is deprecated. + const toolSkills = options.tools + ?.map((tool) => + tool.name === 'code_execution' + ? readCodeExecutionSkills(tool) + : undefined, + ) + .find((skills) => skills && skills.length > 0) + + if (toolSkills && toolSkills.length > 0) { + const existingContainer = validProviderOptions.container ?? undefined + validProviderOptions.container = { + id: existingContainer?.id ?? null, + skills: toolSkills, + } + } + // `temperature`/`top_p` arrive via `...validProviderOptions` (sourced from // `modelOptions`). `InternalTextProviderOptions` declares `system` and // `tools` as `T?: ...` (no `| undefined`), so spread them conditionally diff --git a/packages/ai-anthropic/src/text/text-provider-options.ts b/packages/ai-anthropic/src/text/text-provider-options.ts index c75ab26f7..90cbad5ae 100644 --- a/packages/ai-anthropic/src/text/text-provider-options.ts +++ b/packages/ai-anthropic/src/text/text-provider-options.ts @@ -5,7 +5,7 @@ import type { BetaToolChoiceTool, } from '@anthropic-ai/sdk/resources/beta/messages/messages' import type { CacheControlEphemeral } from '@anthropic-ai/sdk/resources' -import type { AnthropicTool } from '../tools' +import type { AnthropicContainerSkill, AnthropicTool } from '../tools' import type { MessageParam, TextBlockParam, @@ -49,20 +49,14 @@ export interface AnthropicContainerOptions { container?: { id: string | null /** - * List of skills to load into the container + * List of skills to load into the container. + * + * @deprecated Configure skills on the `code_execution` tool instead: + * `codeExecutionTool(config, { skills })`. The adapter lifts those into + * `container.skills` and attaches the required beta headers. Setting + * skills here bypasses the beta-header wiring and may stop working. */ - skills: Array<{ - /** - * Between 1-64 characters - */ - skill_id: string - - type: 'anthropic' | 'custom' - /** - * Skill version or latest by default - */ - version?: string - }> | null + skills: Array | null } | null } diff --git a/packages/ai-anthropic/src/tools/code-execution-tool.ts b/packages/ai-anthropic/src/tools/code-execution-tool.ts index 1e4661d9a..668ffbca3 100644 --- a/packages/ai-anthropic/src/tools/code-execution-tool.ts +++ b/packages/ai-anthropic/src/tools/code-execution-tool.ts @@ -12,6 +12,29 @@ export type CodeExecutionToolConfig = /** @deprecated Renamed to `CodeExecutionToolConfig`. Will be removed in a future release. */ export type CodeExecutionTool = CodeExecutionToolConfig +/** + * A hosted/managed Anthropic Skill reference. Lifted by the text adapter into + * the top-level `container.skills` request param (NOT serialized into the + * `tools[]` entry). Requires the `code_execution` tool to be enabled. + */ +export interface AnthropicContainerSkill { + /** 1–64 characters. */ + skill_id: string + type: 'anthropic' | 'custom' + /** Skill version, or `'latest'` (default) when omitted. */ + version?: string +} + +export interface CodeExecutionToolOptions { + /** Hosted skills to load into the code-execution container (max 8). */ + skills?: Array +} + +interface CodeExecutionToolMetadata { + config: CodeExecutionToolConfig + skills?: Array +} + export type AnthropicCodeExecutionTool = ProviderTool< 'anthropic', 'code_execution' @@ -20,16 +43,54 @@ export type AnthropicCodeExecutionTool = ProviderTool< export function convertCodeExecutionToolToAdapterFormat( tool: Tool, ): CodeExecutionToolConfig { - const metadata = tool.metadata as CodeExecutionToolConfig - return metadata + // The converter is only called for real `code_execution` tools, so a + // non-undefined config is expected — but read via optional chaining so an + // absent metadata object doesn't throw. + return readCodeExecutionConfig(tool) as CodeExecutionToolConfig +} + +/** + * Reads the SDK tool config attached to a `code_execution` tool, if any. + * Used by the text adapter to select the version-aware code-execution beta. + */ +export function readCodeExecutionConfig( + tool: Tool, +): CodeExecutionToolConfig | undefined { + return (tool.metadata as CodeExecutionToolMetadata | undefined)?.config +} + +/** + * Reads the hosted skills attached to a `code_execution` tool, if any. + * Used by the text adapter to build the top-level `container.skills` param. + */ +export function readCodeExecutionSkills( + tool: Tool, +): Array | undefined { + return (tool.metadata as CodeExecutionToolMetadata | undefined)?.skills } export function codeExecutionTool( config: CodeExecutionToolConfig, + options: CodeExecutionToolOptions = {}, ): AnthropicCodeExecutionTool { + const { skills } = options + if (skills) { + if (skills.length > 8) { + throw new Error('code_execution supports at most 8 skills per request.') + } + for (const skill of skills) { + if (skill.skill_id.length < 1 || skill.skill_id.length > 64) { + throw new Error('skill_id must be between 1 and 64 characters.') + } + } + } + const metadata: CodeExecutionToolMetadata = { + config, + ...(skills && { skills }), + } return brandProviderTool({ name: 'code_execution', description: '', - metadata: config, + metadata, }) } diff --git a/packages/ai-anthropic/src/tools/index.ts b/packages/ai-anthropic/src/tools/index.ts index 7e3beeede..9ad77c043 100644 --- a/packages/ai-anthropic/src/tools/index.ts +++ b/packages/ai-anthropic/src/tools/index.ts @@ -15,8 +15,13 @@ export { } from './bash-tool' export { codeExecutionTool, + convertCodeExecutionToolToAdapterFormat, + readCodeExecutionConfig, + readCodeExecutionSkills, type AnthropicCodeExecutionTool, + type AnthropicContainerSkill, type CodeExecutionToolConfig, + type CodeExecutionToolOptions, type CodeExecutionTool, } from './code-execution-tool' export { diff --git a/packages/ai-anthropic/tests/code-execution-tool.test.ts b/packages/ai-anthropic/tests/code-execution-tool.test.ts new file mode 100644 index 000000000..a480bce09 --- /dev/null +++ b/packages/ai-anthropic/tests/code-execution-tool.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + codeExecutionTool, + convertCodeExecutionToolToAdapterFormat, + readCodeExecutionSkills, +} from '../src/tools/code-execution-tool' + +const config = { + type: 'code_execution_20250825', + name: 'code_execution', +} as const + +describe('codeExecutionTool', () => { + it('converts to the bare SDK tool config (skills are NOT in the wire tool)', () => { + const tool = codeExecutionTool(config, { + skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }], + }) + expect(convertCodeExecutionToolToAdapterFormat(tool)).toEqual(config) + }) + + it('exposes attached skills via readCodeExecutionSkills', () => { + const tool = codeExecutionTool(config, { + skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }], + }) + expect(readCodeExecutionSkills(tool)).toEqual([ + { type: 'anthropic', skill_id: 'pptx', version: 'latest' }, + ]) + }) + + it('returns undefined skills when none attached', () => { + expect(readCodeExecutionSkills(codeExecutionTool(config))).toBeUndefined() + }) + + it('rejects more than 8 skills', () => { + const skills = Array.from({ length: 9 }, (_, i) => ({ + type: 'anthropic' as const, + skill_id: `s${i}`, + })) + expect(() => codeExecutionTool(config, { skills })).toThrow(/at most 8/i) + }) + + it('rejects an empty skill_id', () => { + expect(() => + codeExecutionTool(config, { + skills: [{ type: 'anthropic', skill_id: '' }], + }), + ).toThrow(/between 1 and 64|skill_id/i) + }) + + it('rejects a skill_id longer than 64 characters', () => { + expect(() => + codeExecutionTool(config, { + skills: [{ type: 'anthropic', skill_id: 'a'.repeat(65) }], + }), + ).toThrow(/between 1 and 64|skill_id/i) + }) + + it('accepts a 64-character skill_id (boundary)', () => { + expect(() => + codeExecutionTool(config, { + skills: [{ type: 'anthropic', skill_id: 'a'.repeat(64) }], + }), + ).not.toThrow() + }) +}) diff --git a/packages/ai-anthropic/tests/text.skills.test.ts b/packages/ai-anthropic/tests/text.skills.test.ts new file mode 100644 index 000000000..7d9ea4134 --- /dev/null +++ b/packages/ai-anthropic/tests/text.skills.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { + computeAnthropicBetas, + createAnthropicChat, +} from '../src/adapters/text' +import { codeExecutionTool } from '../src/tools' +import { createSilentLogger } from './utils/logger' + +function makeAdapter() { + return createAnthropicChat('claude-opus-4-8' as any, 'test-key') as any +} + +function baseOptions(overrides: Record = {}) { + return { + model: 'claude-opus-4-8', + messages: [{ role: 'user', content: 'hi' }], + logger: createSilentLogger(), + ...overrides, + } as any +} + +describe('anthropic skills → container', () => { + it('lifts code_execution tool skills into top-level container.skills', () => { + const adapter = makeAdapter() + const tool = codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }] }, + ) + const req = adapter.mapCommonOptionsToAnthropic( + baseOptions({ tools: [tool] }), + ) + expect(req.container?.skills).toEqual([ + { type: 'anthropic', skill_id: 'pptx', version: 'latest' }, + ]) + }) + + it('preserves container.id from modelOptions while adding tool skills', () => { + const adapter = makeAdapter() + const tool = codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { skills: [{ type: 'anthropic', skill_id: 'xlsx' }] }, + ) + const req = adapter.mapCommonOptionsToAnthropic( + baseOptions({ + tools: [tool], + modelOptions: { container: { id: 'ctr_1', skills: null } }, + }), + ) + expect(req.container?.id).toBe('ctr_1') + expect(req.container?.skills).toEqual([ + { type: 'anthropic', skill_id: 'xlsx' }, + ]) + }) + + it('leaves container undefined when no skills are attached', () => { + const adapter = makeAdapter() + const tool = codeExecutionTool({ + type: 'code_execution_20250825', + name: 'code_execution', + }) + const req = adapter.mapCommonOptionsToAnthropic( + baseOptions({ tools: [tool] }), + ) + expect(req.container).toBeUndefined() + }) +}) + +describe('computeAnthropicBetas', () => { + it('adds code-execution + skills betas when a code_execution tool has skills', () => { + const tool = codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { skills: [{ type: 'anthropic', skill_id: 'pptx' }] }, + ) + const betas = computeAnthropicBetas([tool], undefined) + expect(betas).toContain('code-execution-2025-08-25') + expect(betas).toContain('skills-2025-10-02') + }) + + it('adds code-execution beta (no skills beta) for a bare code_execution tool', () => { + const tool = codeExecutionTool({ + type: 'code_execution_20250825', + name: 'code_execution', + }) + const betas = computeAnthropicBetas([tool], undefined) + expect(betas).toContain('code-execution-2025-08-25') + expect(betas).not.toContain('skills-2025-10-02') + }) + + it('keeps interleaved-thinking when enabled and unions with skills betas', () => { + const tool = codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { skills: [{ type: 'anthropic', skill_id: 'pptx' }] }, + ) + const betas = computeAnthropicBetas([tool], { + thinking: { type: 'enabled', budget_tokens: 2048 }, + } as any) + expect(betas).toEqual( + expect.arrayContaining([ + 'interleaved-thinking-2025-05-14', + 'code-execution-2025-08-25', + 'skills-2025-10-02', + ]), + ) + }) + + it('returns undefined when nothing requires a beta', () => { + expect(computeAnthropicBetas(undefined, undefined)).toBeUndefined() + }) + + it('uses code-execution-2025-05-22 beta for legacy code_execution_20250522 tool', () => { + const tool = codeExecutionTool({ + type: 'code_execution_20250522', + name: 'code_execution', + }) + const betas = computeAnthropicBetas([tool], undefined) + expect(betas).toContain('code-execution-2025-05-22') + expect(betas).not.toContain('code-execution-2025-08-25') + }) + + it('adds skills beta when ANY code_execution tool carries skills (scan-all)', () => { + // The skills are on the SECOND tool. The old first-tool-only logic would + // miss them; the scan-all logic must agree with the container-lift. + const codeExecToolWithoutSkills = codeExecutionTool({ + type: 'code_execution_20250825', + name: 'code_execution', + }) + const codeExecToolWithSkills = codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { skills: [{ type: 'anthropic', skill_id: 'pptx' }] }, + ) + const betas = computeAnthropicBetas( + [codeExecToolWithoutSkills, codeExecToolWithSkills], + undefined, + ) + expect(betas).toContain('skills-2025-10-02') + }) + + it('returns undefined for a non-code_execution tool, and maps no container', () => { + const fnTool = { name: 'noop', description: '', metadata: {} } as any + expect(computeAnthropicBetas([fnTool], undefined)).toBeUndefined() + + const adapter = makeAdapter() + const req = adapter.mapCommonOptionsToAnthropic( + baseOptions({ tools: [fnTool] }), + ) + expect(req.container).toBeUndefined() + }) +}) diff --git a/packages/ai-anthropic/tests/utils/logger.ts b/packages/ai-anthropic/tests/utils/logger.ts new file mode 100644 index 000000000..17cc4c73c --- /dev/null +++ b/packages/ai-anthropic/tests/utils/logger.ts @@ -0,0 +1,18 @@ +import type { InternalLogger } from '@tanstack/ai/adapter-internals' + +/** No-op logger for unit tests that exercise request mapping. */ +export function createSilentLogger(): InternalLogger { + const noop = () => {} + return { + isEnabled: () => false, + request: noop, + provider: noop, + output: noop, + middleware: noop, + tools: noop, + agentLoop: noop, + config: noop, + errors: noop, + warn: noop, + } as unknown as InternalLogger +} diff --git a/packages/ai-grok/package.json b/packages/ai-grok/package.json index a99a0c8fe..f8fffb058 100644 --- a/packages/ai-grok/package.json +++ b/packages/ai-grok/package.json @@ -53,7 +53,7 @@ "dependencies": { "@tanstack/ai-utils": "workspace:*", "@tanstack/openai-base": "workspace:*", - "openai": "^6.9.1" + "openai": "^6.41.0" }, "devDependencies": { "@tanstack/ai": "workspace:*", diff --git a/packages/ai-groq/package.json b/packages/ai-groq/package.json index bbc2bc205..44e3ebdde 100644 --- a/packages/ai-groq/package.json +++ b/packages/ai-groq/package.json @@ -60,6 +60,6 @@ "dependencies": { "@tanstack/ai-utils": "workspace:*", "@tanstack/openai-base": "workspace:*", - "openai": "^6.9.1" + "openai": "^6.41.0" } } diff --git a/packages/ai-openai/package.json b/packages/ai-openai/package.json index 8b365baf5..f642352c7 100644 --- a/packages/ai-openai/package.json +++ b/packages/ai-openai/package.json @@ -60,7 +60,7 @@ "dependencies": { "@tanstack/ai-utils": "workspace:*", "@tanstack/openai-base": "workspace:*", - "openai": "^6.9.1" + "openai": "^6.41.0" }, "peerDependencies": { "@tanstack/ai": "workspace:^", diff --git a/packages/ai-openai/src/adapters/image.ts b/packages/ai-openai/src/adapters/image.ts index 174399900..8e980b3d1 100644 --- a/packages/ai-openai/src/adapters/image.ts +++ b/packages/ai-openai/src/adapters/image.ts @@ -76,13 +76,7 @@ export class OpenAIImageAdapter< ...(modelOptions ?? {}), } if (size !== undefined) { - // Index into ImageGenerateParams['size'] gives `... | null | undefined`; - // strip `undefined` so the value matches the SDK's `size?: ... | null` - // shape under exactOptionalPropertyTypes. - request.size = size as Exclude< - OpenAI_SDK.Images.ImageGenerateParams['size'], - undefined - > + request.size = size } try { diff --git a/packages/ai-openai/src/adapters/video.ts b/packages/ai-openai/src/adapters/video.ts index 2bb9df046..6bf4652f8 100644 --- a/packages/ai-openai/src/adapters/video.ts +++ b/packages/ai-openai/src/adapters/video.ts @@ -8,7 +8,6 @@ import { validateVideoSeconds, validateVideoSize, } from '../video/video-provider-options' -import type { VideoModel } from 'openai/resources' import type { VideoGenerationOptions, VideoJobResult, @@ -94,7 +93,7 @@ export class OpenAIVideoAdapter< validateVideoSeconds(model, seconds) const request: OpenAI_SDK.Videos.VideoCreateParams = { - model: model as VideoModel, + model, prompt: options.prompt, } // `VideoCreateParams.size` is `size?: VideoSize` (no `| undefined`), so we diff --git a/packages/ai-openai/src/tools/shell-tool.ts b/packages/ai-openai/src/tools/shell-tool.ts index 9f48503a4..77e45acfc 100644 --- a/packages/ai-openai/src/tools/shell-tool.ts +++ b/packages/ai-openai/src/tools/shell-tool.ts @@ -1,17 +1,22 @@ import { shellTool as baseShellTool } from '@tanstack/openai-base' import type { ProviderTool } from '@tanstack/ai' +import type { ShellToolFactoryConfig } from '@tanstack/openai-base' export { type ShellToolConfig, type ShellTool, + type ShellToolFactoryConfig, convertShellToolToAdapterFormat, } from '@tanstack/openai-base' export type OpenAIShellTool = ProviderTool<'openai', 'shell'> /** - * Creates a standard Tool from ShellTool parameters, branded as an OpenAI provider tool. + * Creates a standard Tool from ShellTool parameters, branded as an OpenAI + * provider tool. Pass `environment` to attach a container + skills. */ -export function shellTool(): OpenAIShellTool { - return baseShellTool() as OpenAIShellTool +export function shellTool( + config: ShellToolFactoryConfig = {}, +): OpenAIShellTool { + return baseShellTool(config) as OpenAIShellTool } diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index d15dfaa54..a72c94f36 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -375,6 +375,86 @@ gets the full schema, then calls `compareProducts` directly. Once discovered, a tool stays available for the conversation. When all lazy tools are discovered, the discovery tool is removed automatically. +## Provider Skills + +> **Not to be confused with `@tanstack/ai-code-mode-skills`**, which are locally-generated TypeScript functions executed client-side. Provider Skills are hosted, provider-managed bundles that the model loads on demand and runs inside the provider's server-side sandbox. + +Provider Skills are inert without an execution tool. The execution tool is what activates the sandbox; skills are additional capability bundles that run inside it: + +- **Anthropic**: skills require the `code_execution` tool (`@tanstack/ai-anthropic/tools`). +- **OpenAI**: skills live inside the `shell` tool (`@tanstack/ai-openai/tools`) and are Responses API only. + +### Anthropic: `codeExecutionTool` with skills + +Import from `@tanstack/ai-anthropic/tools`: + +```typescript +import { codeExecutionTool } from '@tanstack/ai-anthropic/tools' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' + +export async function POST(request: Request) { + const { messages } = await request.json() + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + tools: [ + codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { + skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }], + }, + ), + ], + }) + return toServerSentEventsResponse(stream) +} +``` + +`AnthropicContainerSkill` shape: `{ type: 'anthropic' | 'custom'; skill_id: string; version?: string }`. Constraints: max 8 skills per request; `skill_id` must be 1–64 characters. + +The adapter automatically: + +- Lifts the skills into the request's top-level `container.skills` param (the shape Anthropic's API requires). +- Attaches the required beta headers (`code-execution-2025-08-25` plus `skills-2025-10-02` when skills are present). You do not set these manually. + +**Deprecation:** Setting skills via `modelOptions.container.skills` is deprecated. Use `codeExecutionTool(config, { skills })` instead — the legacy path bypasses the beta-header wiring. + +### OpenAI: `shellTool` with skills (Responses API only) + +Import from `@tanstack/ai-openai/tools`: + +```typescript +import { shellTool } from '@tanstack/ai-openai/tools' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(request: Request) { + const { messages } = await request.json() + const stream = chat({ + adapter: openaiText('gpt-5.2'), + messages, + tools: [ + shellTool({ + environment: { + type: 'container_auto', + skills: [ + { type: 'skill_reference', skill_id: 'skill_abc', version: '2' }, + ], + }, + }), + ], + }) + return toServerSentEventsResponse(stream) +} +``` + +`SkillReference` shape: `{ type: 'skill_reference'; skill_id: string; version?: string }`. `version` is a string — use a positive integer as a string (e.g. `'2'`) or `'latest'`. This is Responses API only; Chat Completions does not support the shell tool. + +### Scope + +Only hosted/managed-by-id skills (`type: 'anthropic'` / `type: 'custom'` for Anthropic; `type: 'skill_reference'` for OpenAI) are wired. Inline bundles, local-path, and upload-API skill creation are not handled by these factories. + ## Common Mistakes ### a. HIGH: Not passing tool definitions to both server and client diff --git a/packages/openai-base/package.json b/packages/openai-base/package.json index 56b19143c..d43c16272 100644 --- a/packages/openai-base/package.json +++ b/packages/openai-base/package.json @@ -46,7 +46,7 @@ ], "dependencies": { "@tanstack/ai-utils": "workspace:*", - "openai": "^6.9.1" + "openai": "^6.41.0" }, "peerDependencies": { "@tanstack/ai": "workspace:^" diff --git a/packages/openai-base/src/tools/computer-use-tool.ts b/packages/openai-base/src/tools/computer-use-tool.ts index c79481a03..4ccb1d108 100644 --- a/packages/openai-base/src/tools/computer-use-tool.ts +++ b/packages/openai-base/src/tools/computer-use-tool.ts @@ -1,4 +1,4 @@ -import type { ComputerTool as ComputerUseToolConfig } from 'openai/resources/responses/responses' +import type { ComputerUsePreviewTool as ComputerUseToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' export type { ComputerUseToolConfig } diff --git a/packages/openai-base/src/tools/shell-tool.ts b/packages/openai-base/src/tools/shell-tool.ts index 6c797d8b7..66df1a946 100644 --- a/packages/openai-base/src/tools/shell-tool.ts +++ b/packages/openai-base/src/tools/shell-tool.ts @@ -7,11 +7,26 @@ export type { ShellToolConfig } export type ShellTool = ShellToolConfig /** - * Converts a standard Tool to OpenAI ShellTool format + * Config accepted by {@link shellTool}. `environment` mirrors the OpenAI + * Responses API shell tool environment (e.g. `container_auto` + `skills`). + * Typed via indexed access so it tracks the installed SDK without naming the + * union members directly. */ -export function convertShellToolToAdapterFormat(_tool: Tool): ShellToolConfig { +export interface ShellToolFactoryConfig { + environment?: NonNullable +} + +/** + * Converts a standard Tool to OpenAI ShellTool format, preserving any + * `environment` (container config + skills) stored in metadata. + */ +export function convertShellToolToAdapterFormat(tool: Tool): ShellToolConfig { + const metadata = (tool.metadata ?? {}) as ShellToolFactoryConfig return { type: 'shell', + ...(metadata.environment !== undefined && { + environment: metadata.environment, + }), } } @@ -21,10 +36,14 @@ export function convertShellToolToAdapterFormat(_tool: Tool): ShellToolConfig { * Base (non-branded) factory. Providers that need branded return types should * re-wrap this in their own package. */ -export function shellTool(): Tool { +export function shellTool(config: ShellToolFactoryConfig = {}): Tool { return { name: 'shell', description: 'Execute shell commands', - metadata: {}, + metadata: { + ...(config.environment !== undefined && { + environment: config.environment, + }), + }, } } diff --git a/packages/openai-base/tests/shell-tool.test.ts b/packages/openai-base/tests/shell-tool.test.ts new file mode 100644 index 000000000..fa0643a07 --- /dev/null +++ b/packages/openai-base/tests/shell-tool.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + convertShellToolToAdapterFormat, + shellTool, +} from '../src/tools/shell-tool' + +describe('shellTool', () => { + it('defaults to a bare shell tool with no environment', () => { + const tool = shellTool() + expect(convertShellToolToAdapterFormat(tool)).toEqual({ type: 'shell' }) + }) + + it('passes a container_auto environment with skill references through the converter', () => { + const tool = shellTool({ + environment: { + type: 'container_auto', + skills: [ + { type: 'skill_reference', skill_id: 'skill_abc', version: '2' }, + ], + }, + }) + expect(convertShellToolToAdapterFormat(tool)).toEqual({ + type: 'shell', + environment: { + type: 'container_auto', + skills: [ + { type: 'skill_reference', skill_id: 'skill_abc', version: '2' }, + ], + }, + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b025e0e39..01a7a1db0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1272,8 +1272,8 @@ importers: specifier: workspace:* version: link:../openai-base openai: - specifier: ^6.9.1 - version: 6.10.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.41.0 + version: 6.41.0(ws@8.19.0)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1303,8 +1303,8 @@ importers: specifier: workspace:* version: link:../openai-base openai: - specifier: ^6.9.1 - version: 6.10.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.41.0 + version: 6.41.0(ws@8.19.0)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1386,8 +1386,8 @@ importers: specifier: workspace:* version: link:../openai-base openai: - specifier: ^6.9.1 - version: 6.10.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.41.0 + version: 6.41.0(ws@8.19.0)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1723,8 +1723,8 @@ importers: specifier: workspace:* version: link:../ai-utils openai: - specifier: ^6.9.1 - version: 6.10.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.41.0 + version: 6.41.0(ws@8.19.0)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1805,8 +1805,8 @@ importers: testing/e2e: dependencies: '@copilotkit/aimock': - specifier: ^1.27.0 - version: 1.27.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + specifier: ^1.28.1 + version: 1.29.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@openrouter/sdk': specifier: 0.12.35 version: 0.12.35 @@ -2739,9 +2739,9 @@ packages: '@cloudflare/workers-types@4.20260317.1': resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==} - '@copilotkit/aimock@1.27.0': - resolution: {integrity: sha512-8NNDhKCc9FBK+tD9bTufZl/v4EOXUZ6OBin4YlJUegHFKLTIIgHemyVqlzUYl4r2XjslAr8ilPwFf4p2O8ksAQ==} - engines: {node: '>=24.0.0'} + '@copilotkit/aimock@1.29.0': + resolution: {integrity: sha512-xNMHMUDX7zPSc56dm2ZXbttoLk6x72oEBHwWCAakVWNO85zZepLkB8Poc/x1cJrY69FI9frN0gavI/zVuZq/9A==} + engines: {node: '>=20.15.0'} hasBin: true peerDependencies: jest: '>=29' @@ -11180,9 +11180,8 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} - openai@6.10.0: - resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} - hasBin: true + openai@6.41.0: + resolution: {integrity: sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==} peerDependencies: ws: ^8.18.0 zod: ^3.25 || ^4.0 @@ -14689,7 +14688,7 @@ snapshots: '@cloudflare/workers-types@4.20260317.1': {} - '@copilotkit/aimock@1.27.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@copilotkit/aimock@1.29.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': optionalDependencies: vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -24467,7 +24466,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.10.0(ws@8.19.0)(zod@4.3.6): + openai@6.41.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 zod: 4.3.6 diff --git a/testing/e2e/fixtures/video-gen/basic.json b/testing/e2e/fixtures/video-gen/basic.json index c7649b4f6..e1f048cf2 100644 --- a/testing/e2e/fixtures/video-gen/basic.json +++ b/testing/e2e/fixtures/video-gen/basic.json @@ -4,6 +4,8 @@ "match": { "userMessage": "a guitar being played in a store" }, "response": { "video": { + "id": "video_test_basic", + "status": "completed", "url": "https://example.com/guitar-store.mp4", "duration": 10 } diff --git a/testing/e2e/package.json b/testing/e2e/package.json index cebaf1b74..f4d096a83 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -11,7 +11,7 @@ "postinstall": "playwright install chromium" }, "dependencies": { - "@copilotkit/aimock": "^1.27.0", + "@copilotkit/aimock": "^1.28.1", "@openrouter/sdk": "0.12.35", "@opentelemetry/api": "^1.9.0", "@tailwindcss/vite": "^4.1.18", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index b0e11446b..55db5b744 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -28,12 +28,14 @@ import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire' import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost' import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details' +import { Route as ApiOpenaiShellSkillsWireRouteImport } from './routes/api.openai-shell-skills-wire' import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire' import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire' +import { Route as ApiAnthropicSkillsWireRouteImport } from './routes/api.anthropic-skills-wire' import { Route as ApiAnthropicBugTestRouteImport } from './routes/api.anthropic-bug-test' import { Route as ProviderFeatureRouteImport } from './routes/$provider/$feature' import { Route as ApiVideoStreamRouteImport } from './routes/api.video.stream' @@ -138,6 +140,12 @@ const ApiOpenaiUsageDetailsRoute = ApiOpenaiUsageDetailsRouteImport.update({ path: '/api/openai-usage-details', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenaiShellSkillsWireRoute = + ApiOpenaiShellSkillsWireRouteImport.update({ + id: '/api/openai-shell-skills-wire', + path: '/api/openai-shell-skills-wire', + getParentRoute: () => rootRouteImport, + } as any) const ApiMultimodalToolResultWireRoute = ApiMultimodalToolResultWireRouteImport.update({ id: '/api/multimodal-tool-result-wire', @@ -169,6 +177,11 @@ const ApiArktypeToolWireRoute = ApiArktypeToolWireRouteImport.update({ path: '/api/arktype-tool-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiAnthropicSkillsWireRoute = ApiAnthropicSkillsWireRouteImport.update({ + id: '/api/anthropic-skills-wire', + path: '/api/anthropic-skills-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiAnthropicBugTestRoute = ApiAnthropicBugTestRouteImport.update({ id: '/api/anthropic-bug-test', path: '/api/anthropic-bug-test', @@ -218,12 +231,14 @@ export interface FileRoutesByFullPath { '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute + '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute @@ -252,12 +267,14 @@ export interface FileRoutesByTo { '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute + '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute @@ -287,12 +304,14 @@ export interface FileRoutesById { '/tools-test': typeof ToolsTestRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute + '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute '/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute @@ -323,12 +342,14 @@ export interface FileRouteTypes { | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' + | '/api/anthropic-skills-wire' | '/api/arktype-tool-wire' | '/api/audio' | '/api/chat' | '/api/image' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' | '/api/openrouter-web-tools-wire' @@ -357,12 +378,14 @@ export interface FileRouteTypes { | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' + | '/api/anthropic-skills-wire' | '/api/arktype-tool-wire' | '/api/audio' | '/api/chat' | '/api/image' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' | '/api/openrouter-web-tools-wire' @@ -391,12 +414,14 @@ export interface FileRouteTypes { | '/tools-test' | '/$provider/$feature' | '/api/anthropic-bug-test' + | '/api/anthropic-skills-wire' | '/api/arktype-tool-wire' | '/api/audio' | '/api/chat' | '/api/image' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' | '/api/openrouter-web-tools-wire' @@ -426,12 +451,14 @@ export interface RootRouteChildren { ToolsTestRoute: typeof ToolsTestRoute ProviderFeatureRoute: typeof ProviderFeatureRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute + ApiAnthropicSkillsWireRoute: typeof ApiAnthropicSkillsWireRoute ApiArktypeToolWireRoute: typeof ApiArktypeToolWireRoute ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute ApiImageRoute: typeof ApiImageRouteWithChildren ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute + ApiOpenaiShellSkillsWireRoute: typeof ApiOpenaiShellSkillsWireRoute ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute @@ -578,6 +605,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenaiUsageDetailsRouteImport parentRoute: typeof rootRouteImport } + '/api/openai-shell-skills-wire': { + id: '/api/openai-shell-skills-wire' + path: '/api/openai-shell-skills-wire' + fullPath: '/api/openai-shell-skills-wire' + preLoaderRoute: typeof ApiOpenaiShellSkillsWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/multimodal-tool-result-wire': { id: '/api/multimodal-tool-result-wire' path: '/api/multimodal-tool-result-wire' @@ -620,6 +654,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiArktypeToolWireRouteImport parentRoute: typeof rootRouteImport } + '/api/anthropic-skills-wire': { + id: '/api/anthropic-skills-wire' + path: '/api/anthropic-skills-wire' + fullPath: '/api/anthropic-skills-wire' + preLoaderRoute: typeof ApiAnthropicSkillsWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/anthropic-bug-test': { id: '/api/anthropic-bug-test' path: '/api/anthropic-bug-test' @@ -743,12 +784,14 @@ const rootRouteChildren: RootRouteChildren = { ToolsTestRoute: ToolsTestRoute, ProviderFeatureRoute: ProviderFeatureRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, + ApiAnthropicSkillsWireRoute: ApiAnthropicSkillsWireRoute, ApiArktypeToolWireRoute: ApiArktypeToolWireRoute, ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, ApiImageRoute: ApiImageRouteWithChildren, ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, + ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute, ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute, ApiOpenrouterCostRoute: ApiOpenrouterCostRoute, ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute, diff --git a/testing/e2e/src/routes/api.anthropic-skills-wire.ts b/testing/e2e/src/routes/api.anthropic-skills-wire.ts new file mode 100644 index 000000000..14ac8eec1 --- /dev/null +++ b/testing/e2e/src/routes/api.anthropic-skills-wire.ts @@ -0,0 +1,162 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions } from '@tanstack/ai' +import { createAnthropicChat } from '@tanstack/ai-anthropic' +import { codeExecutionTool } from '@tanstack/ai-anthropic/tools' + +const DUMMY_KEY = 'sk-ant-e2e-test-dummy-key' + +/** + * Drives the Anthropic chat adapter with a `codeExecutionTool` carrying a + * hosted skill. A custom `fetch` implementation intercepts the outgoing + * request before it reaches aimock, capturing both the raw request body + * (which includes `container.skills`) and the HTTP headers (which include + * `anthropic-beta`). + * + * The captured data is returned as JSON so the companion spec can assert: + * 1. `capturedRequest.body.container.skills` contains the skill reference. + * 2. `capturedRequest.headers['anthropic-beta']` includes both + * `code-execution-2025-08-25` and `skills-2025-10-02`. + * + * The fake fetch returns a minimal synthetic Claude SSE response so the + * `chat()` call can finish without a real Anthropic API key or a live + * aimock fixture. + */ + +/** Minimal Anthropic Messages streaming response (end_turn, no content). */ +function makeSyntheticAnthropicStream(): ReadableStream { + const encoder = new TextEncoder() + const events = [ + { + type: 'message_start', + message: { + id: 'msg_wire_test', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'ok' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 2 }, + }, + { type: 'message_stop' }, + ] + + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue( + encoder.encode( + `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`, + ), + ) + } + controller.close() + }, + }) +} + +export const Route = createFileRoute('/api/anthropic-skills-wire')({ + server: { + handlers: { + POST: async () => { + let capturedRequest: { + url: string + headers: Record + body: unknown + } | null = null + + // Custom fetch that captures the outgoing request and returns a + // synthetic Anthropic SSE response without touching a real server. + const capturingFetch: typeof fetch = async (input, init) => { + const req = + input instanceof Request ? input : new Request(input, init) + const url = req.url + const headers: Record = {} + req.headers.forEach((value, key) => { + headers[key] = value + }) + let body: unknown = null + try { + const rawBody = await req.text() + if (rawBody) { + body = JSON.parse(rawBody) + } + } catch { + // Ignore parse errors — body stays null + } + capturedRequest = { url, headers, body } + + return new Response(makeSyntheticAnthropicStream(), { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + }) + } + + const adapter = createAnthropicChat('claude-sonnet-4-5', DUMMY_KEY, { + fetch: capturingFetch, + }) + + try { + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages: [ + { + role: 'user', + content: '[skills-wire] test code execution with skill', + }, + ], + tools: [ + codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { + skills: [ + { + type: 'anthropic', + skill_id: 'pptx', + version: 'latest', + }, + ], + }, + ), + ], + })) { + // Drain the stream. + } + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + return new Response(JSON.stringify({ ok: true, capturedRequest }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/api.openai-shell-skills-wire.ts b/testing/e2e/src/routes/api.openai-shell-skills-wire.ts new file mode 100644 index 000000000..58e1180a4 --- /dev/null +++ b/testing/e2e/src/routes/api.openai-shell-skills-wire.ts @@ -0,0 +1,208 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { shellTool } from '@tanstack/ai-openai/tools' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +/** + * Drives the OpenAI chat adapter (Responses API) with a `shellTool` that + * carries a `container_auto` environment + skills reference. A custom `fetch` + * implementation intercepts the outgoing request before it reaches the + * provider, capturing the raw request body so the companion spec can assert + * the tools array on the wire. + * + * The captured data is returned as JSON so the companion spec can assert: + * `capturedRequest.body.tools[0]` equals + * `{ type: 'shell', environment: { type: 'container_auto', skills: [{ type: 'skill_reference', skill_id: 'skill_abc', version: '2' }] } }` + * + * The fake fetch returns a minimal synthetic OpenAI Responses API SSE + * response so the `chat()` call can finish without a real OpenAI API key + * or a live aimock fixture. + */ + +/** Minimal OpenAI Responses API streaming response (stop, no tool calls). */ +function makeSyntheticOpenAIResponsesStream(): ReadableStream { + const encoder = new TextEncoder() + const responseId = 'resp_wire_test' + const events = [ + { + type: 'response.created', + response: { + id: responseId, + object: 'realtime.response', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_item.added', + response_id: responseId, + output_index: 0, + item: { id: 'msg_wire', type: 'message', role: 'assistant', content: [] }, + }, + { + type: 'response.content_part.added', + response_id: responseId, + item_id: 'msg_wire', + output_index: 0, + content_part_index: 0, + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.output_text.delta', + response_id: responseId, + item_id: 'msg_wire', + output_index: 0, + content_index: 0, + delta: 'ok', + }, + { + type: 'response.output_text.done', + response_id: responseId, + item_id: 'msg_wire', + output_index: 0, + content_index: 0, + text: 'ok', + }, + { + type: 'response.content_part.done', + response_id: responseId, + item_id: 'msg_wire', + output_index: 0, + content_part_index: 0, + part: { type: 'output_text', text: 'ok' }, + }, + { + type: 'response.output_item.done', + response_id: responseId, + output_index: 0, + item: { + id: 'msg_wire', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'realtime.response', + status: 'completed', + output: [ + { + id: 'msg_wire', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }, + ], + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + }, + }, + }, + ] + + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +export const Route = createFileRoute('/api/openai-shell-skills-wire')({ + server: { + handlers: { + POST: async () => { + let capturedRequest: { + url: string + headers: Record + body: unknown + } | null = null + + // Custom fetch that captures the outgoing request and returns a + // synthetic Responses API SSE response without touching a real server. + const capturingFetch: typeof fetch = async (input, init) => { + const req = + input instanceof Request ? input : new Request(input, init) + const url = req.url + const headers: Record = {} + req.headers.forEach((value, key) => { + headers[key] = value + }) + let body: unknown = null + try { + const rawBody = await req.text() + if (rawBody) { + body = JSON.parse(rawBody) + } + } catch { + // Ignore parse errors — body stays null + } + capturedRequest = { url, headers, body } + + return new Response(makeSyntheticOpenAIResponsesStream(), { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + }) + } + + const adapter = createOpenaiChat('gpt-4o', DUMMY_KEY, { + fetch: capturingFetch, + }) + + try { + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages: [ + { + role: 'user', + content: '[shell-skills-wire] test shell tool with skills', + }, + ], + tools: [ + shellTool({ + environment: { + type: 'container_auto', + skills: [ + { + type: 'skill_reference', + skill_id: 'skill_abc', + version: '2', + }, + ], + }, + }), + ], + })) { + // Drain the stream. + } + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + return new Response(JSON.stringify({ ok: true, capturedRequest }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/anthropic-skills-wire.spec.ts b/testing/e2e/tests/anthropic-skills-wire.spec.ts new file mode 100644 index 000000000..8d21f6e40 --- /dev/null +++ b/testing/e2e/tests/anthropic-skills-wire.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from './fixtures' + +/** + * Wire-format verification for the Anthropic provider-skills feature. + * + * When a `codeExecutionTool` is created with a hosted skill reference, the + * Anthropic text adapter must: + * + * 1. Add `code-execution-2025-08-25` AND `skills-2025-10-02` to the + * `anthropic-beta` HTTP request header (via the SDK's `betas` parameter). + * 2. Lift the skill reference into the top-level `container.skills` request + * body param (NOT into the `tools[]` entry). + * + * This spec drives `/api/anthropic-skills-wire` which intercepts the outgoing + * SDK request via a custom `fetch`, captures headers + body, and returns them + * as JSON so we can assert the exact wire shape without needing a real + * Anthropic API key. + */ +test.describe('anthropic — code_execution skills wire format', () => { + test('anthropic-beta header includes code-execution-2025-08-25 and skills-2025-10-02', async ({ + request, + }) => { + const res = await request.post('/api/anthropic-skills-wire') + expect(res.ok()).toBe(true) + const { ok, error, capturedRequest } = (await res.json()) as { + ok: boolean + error?: string + capturedRequest: { + url: string + headers: Record + body: unknown + } | null + } + + if (!ok) { + throw new Error(`Route failed: ${error}`) + } + + expect(capturedRequest).not.toBeNull() + const betaHeader = capturedRequest?.headers['anthropic-beta'] ?? '' + expect(betaHeader).toContain('code-execution-2025-08-25') + expect(betaHeader).toContain('skills-2025-10-02') + }) + + test('request body container.skills contains the skill reference', async ({ + request, + }) => { + const res = await request.post('/api/anthropic-skills-wire') + expect(res.ok()).toBe(true) + const { ok, error, capturedRequest } = (await res.json()) as { + ok: boolean + error?: string + capturedRequest: { + url: string + headers: Record + body: Record | null + } | null + } + + if (!ok) { + throw new Error(`Route failed: ${error}`) + } + + expect(capturedRequest).not.toBeNull() + const body = capturedRequest?.body as Record + expect(body).not.toBeNull() + + const container = body['container'] as + | { skills?: Array> } + | undefined + expect(container).toBeDefined() + expect(Array.isArray(container?.skills)).toBe(true) + expect(container?.skills).toContainEqual({ + type: 'anthropic', + skill_id: 'pptx', + version: 'latest', + }) + }) +}) diff --git a/testing/e2e/tests/openai-shell-skills-wire.spec.ts b/testing/e2e/tests/openai-shell-skills-wire.spec.ts new file mode 100644 index 000000000..099c4be79 --- /dev/null +++ b/testing/e2e/tests/openai-shell-skills-wire.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from './fixtures' + +/** + * Wire-format verification for the OpenAI provider-skills feature. + * + * When a `shellTool` is created with a `container_auto` environment carrying a + * skill reference, the OpenAI text adapter (Responses API) must serialize the + * tool as `{ type: 'shell', environment: { type: 'container_auto', skills: [...] } }` + * in the outgoing `tools[]` array. + * + * This spec drives `/api/openai-shell-skills-wire` which intercepts the + * outgoing SDK request via a custom `fetch`, captures the request body, and + * returns it as JSON so we can assert the exact wire shape without needing a + * real OpenAI API key. + * + * Note: `SkillReference.version` is typed as `string` in the OpenAI SDK, so + * the version value here is the string `'2'`, not the number `2`. + */ +test.describe('openai — shell tool skills wire format', () => { + test('shell tool with container_auto + skills is serialized correctly on the wire', async ({ + request, + }) => { + const res = await request.post('/api/openai-shell-skills-wire') + expect(res.ok()).toBe(true) + const { ok, error, capturedRequest } = (await res.json()) as { + ok: boolean + error?: string + capturedRequest: { + url: string + headers: Record + body: Record | null + } | null + } + + if (!ok) { + throw new Error(`Route failed: ${error}`) + } + + expect(capturedRequest).not.toBeNull() + const body = capturedRequest?.body as Record + expect(body).not.toBeNull() + + const tools = body['tools'] as Array> | undefined + expect(Array.isArray(tools)).toBe(true) + + const shellTool = tools?.find((t) => t['type'] === 'shell') + expect(shellTool).toBeDefined() + expect(shellTool).toMatchObject({ + type: 'shell', + environment: { + type: 'container_auto', + skills: [ + { + type: 'skill_reference', + skill_id: 'skill_abc', + version: '2', + }, + ], + }, + }) + }) +})