diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..e8f7dae --- /dev/null +++ b/.env.local.example @@ -0,0 +1,3 @@ +# Copy this file to .env.local and fill in values for OpenAI comparison examples. +# Only needed for: openai-real, responses-real, responses-advanced-real +OPENAI_API_KEY=sk-... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..135be9c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + paths-ignore: + - "**.md" + - "docs/**" + - LICENSE.md + - NOTICE + pull_request: + branches: [main] + paths-ignore: + - "**.md" + - "docs/**" + - LICENSE.md + - NOTICE + +jobs: + test: + runs-on: macos-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run format:check + - run: npx vitest run tests/unit --coverage + + build-dylib: + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Build native dylib from source + run: | + rm -f native/libFoundationModels.dylib + bash scripts/build-native.sh + - name: Verify dylib + run: | + file native/libFoundationModels.dylib + otool -L native/libFoundationModels.dylib diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index af71216..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: macos-latest - strategy: - matrix: - node-version: [20, 22] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: npm - - run: npm ci - - run: npm run lint - - run: npm run format:check - - run: npx vitest run tests/unit --coverage diff --git a/.gitignore b/.gitignore index 8a84335..0c2047d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage .vscode/settings.json docs/.vitepress/dist docs/.vitepress/cache +.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0efab..f0619be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-03-11 + +### Added + +- **Chat & Responses API layer** (`tsfm-sdk/chat`) — industry-standard Chat-style and Responses-style APIs + - **Chat Completions API** (`client.chat.completions.create()`) with full message history, streaming, structured output (`json_schema`), and tool calling + - **Responses API** (`client.responses.create()`) — string or structured input, function tools, and streaming via `ResponseStream` + - Parameter mapping: `temperature`, `max_tokens`/`max_completion_tokens`, `top_p`, `seed` → native `GenerationOptions`; unsupported params warned at runtime + - Error mapping: `ExceededContextWindowSizeError` → `finish_reason: "length"`, `GuardrailViolationError` → `finish_reason: "content_filter"`, `RefusalError` → `message.refusal`, `RateLimitedError` → HTTP 429 + - `Stream` and `ResponseStream` async iterables with `toReadableStream()`, `close()`, `Symbol.dispose`, and `FinalizationRegistry` cleanup + - Tool calling via structured output with `$defs`/`$ref` schemas to prevent parameter name collisions + - JSON key reordering utility to match schema-defined property order +- `ServiceCrashedError` — detects crashed `generativeexperiencesd` service and provides recovery instructions +- `Symbol.dispose` support on `SystemLanguageModel`, `LanguageModelSession`, `Tool`, and `Client` for TC39 Explicit Resource Management +- Typed transcript entries: `TranscriptEntry`, `TranscriptContent`, `TranscriptTextContent`, `TranscriptStructuredContent`, `TranscriptToolCall`, `TranscriptEntryRole` types and `transcript.entries()` method +- `JsonSchema` and `JsonObject` exported types +- Automatic session cleanup on `process.exit`, `SIGINT`, and `SIGTERM` via global session tracking +- Enhanced `afmSchemaFormat()` with recursive normalization for nested objects, `$defs`/`$ref` support, and `x-order` fields +- `respondWithJsonSchema()` now accepts typed `JsonSchema` instead of `Record` +- Tool callback error handling: synchronous errors in `call()` now invoke `FMBridgedToolFinishCall()` with error message to prevent session hang +- Enhanced `statusToError()`: maps `ModelManagerError Code=1041` to `InvalidGenerationSchemaError` with descriptive message +- Integration tests for Chat & Responses API layer (chat completions and Responses API) +- Unit tests for all compat modules (~4,300 lines of new test coverage) +- 6 new examples in `examples/compat/` demonstrating Chat Completions and Responses API +- Retry helper for integration tests (`retryAttempts()`) for flaky on-device model responses + +### Changed + +- Renamed model class from internal name to `SystemLanguageModel` across all public APIs and documentation +- `Transcript.toDict()` and `fromDict()` now use `JsonObject` type instead of `Record` +- `GeneratedContent.toObject()` now returns `JsonObject` instead of `Record` +- `serializeOptions()` uses typed `SerializedSampling` and `SerializedOptions` interfaces internally +- Integration tests now use `waitUntilAvailable()` instead of synchronous `isAvailable()` + +### Documentation + +- Complete Chat & Responses API guide (505 lines), API reference (568 lines), and examples page (321 lines) +- Docs site visual overhaul: brand colors shifted to teal, Apple-style typography and font rendering, WCAG AA contrast fixes +- Landing page redesigned with code examples and Chat API showcase +- Swift-equivalent references extracted into caption-style info boxes across all guide pages +- Code blocks now word-wrap; inline code uses inherited text color with subtle background +- All guide pages updated with Apple conventions terminology alignment + ## [0.2.3] - 2026-03-10 ### Fixed @@ -84,7 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `build-native.sh` script for building the dylib from vendored Swift source - `verify-native.js` postinstall script for SHA256 verification with automatic rebuild -[Unreleased]: https://github.com/codybrom/tsfm/compare/v0.2.3...HEAD +[Unreleased]: https://github.com/codybrom/tsfm/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/codybrom/tsfm/compare/v0.2.3...v0.3.0 [0.2.3]: https://github.com/codybrom/tsfm/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/codybrom/tsfm/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/codybrom/tsfm/compare/v0.2.0...v0.2.1 diff --git a/README.md b/README.md index 09554be..95d1f38 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

npm - Tests + CI license

@@ -22,6 +22,7 @@ - Structured output with typed schemas and generation guides - Tool calling - Transcript persistence +- Chat-style and Responses-style APIs via `tsfm-sdk/chat` ## Quick Start @@ -56,6 +57,7 @@ model.dispose(); - [Streaming](https://tsfm.dev/guide/streaming) — token-by-token response streaming - [Structured Output](https://tsfm.dev/guide/structured-output) — typed schemas with generation guides - [Tools](https://tsfm.dev/guide/tools) — function calling +- [Chat & Responses APIs](https://tsfm.dev/guide/chat-api) — familiar Chat-style and Responses-style interfaces - [API Reference](https://tsfm.dev/api/) — complete API docs - [Examples](https://tsfm.dev/examples/) — runnable code for every feature diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 96d1f56..f197205 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -22,6 +22,7 @@ export default defineConfig({ ], themeConfig: { + externalLinkIcon: true, logo: "/logo.svg", nav: [ @@ -46,7 +47,6 @@ export default defineConfig({ { text: "Sessions", link: "/guide/sessions" }, { text: "Streaming", link: "/guide/streaming" }, { text: "Structured Output", link: "/guide/structured-output" }, - { text: "JSON Schema", link: "/guide/json-schema" }, { text: "Tools", link: "/guide/tools" }, { text: "Transcripts", link: "/guide/transcripts" }, ], @@ -58,6 +58,15 @@ export default defineConfig({ { text: "Error Handling", link: "/guide/error-handling" }, ], }, + { + text: "Integrations", + items: [ + { + text: "Chat & Responses APIs", + link: "/guide/chat-api", + }, + ], + }, ], "/api/": [ { @@ -71,6 +80,7 @@ export default defineConfig({ { text: "Tool", link: "/api/tool" }, { text: "Transcript", link: "/api/transcript" }, { text: "Errors", link: "/api/errors" }, + { text: "Chat & Responses APIs", link: "/api/chat" }, ], }, ], @@ -87,6 +97,10 @@ export default defineConfig({ { text: "Generation Options", link: "/examples/generation-options" }, { text: "Transcripts", link: "/examples/transcript" }, { text: "Content Tagging", link: "/examples/content-tagging" }, + { + text: "Chat & Responses APIs", + link: "/examples/chat-api", + }, ], }, ], diff --git a/docs/.vitepress/theme/HomeExplore.vue b/docs/.vitepress/theme/HomeExplore.vue new file mode 100644 index 0000000..0a7c47e --- /dev/null +++ b/docs/.vitepress/theme/HomeExplore.vue @@ -0,0 +1,33 @@ + + + diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 8b704c4..3d0b963 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -5,23 +5,23 @@ "Arial", sans-serif; --vp-font-family-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; - /* Brand — Foundation Models green/teal (darkened for white bg contrast) */ - --vp-c-brand-1: #1a7f3f; - --vp-c-brand-2: #1b8f48; - --vp-c-brand-3: #146832; - --vp-c-brand-soft: rgba(26, 127, 63, 0.1); + /* Brand */ + --vp-c-brand-1: #0e6856; + --vp-c-brand-2: #127a65; + --vp-c-brand-3: #0a5445; + --vp-c-brand-soft: rgba(14, 104, 86, 0.1); - /* Surfaces — Apple dev docs palette */ + /* Surfaces */ --vp-c-bg: #ffffff; --vp-c-bg-soft: #f5f5f7; --vp-c-bg-mute: #e8e8ed; - /* Text — Apple gray scale */ + /* Text */ --vp-c-text-1: #1d1d1f; --vp-c-text-2: #6e6e73; - --vp-c-text-3: #86868b; + --vp-c-text-3: #707074; - /* Home hero gradient — matches the FM icon */ + /* Home hero gradient */ --vp-home-hero-name-color: transparent; --vp-home-hero-name-background: linear-gradient(135deg, #6dd57e 0%, #30b8d0 100%); --vp-home-hero-image-background-image: linear-gradient( @@ -34,19 +34,19 @@ /* Buttons */ --vp-button-brand-border: transparent; --vp-button-brand-text: #ffffff; - --vp-button-brand-bg: #1a7f3f; + --vp-button-brand-bg: #0e6856; --vp-button-brand-hover-border: transparent; --vp-button-brand-hover-text: #ffffff; - --vp-button-brand-hover-bg: #1b8f48; + --vp-button-brand-hover-bg: #127a65; --vp-button-brand-active-border: transparent; --vp-button-brand-active-text: #ffffff; - --vp-button-brand-active-bg: #146832; + --vp-button-brand-active-bg: #0a5445; - /* Code — Apple dev docs style */ + /* Code */ --vp-code-block-bg: #f5f5f7; --vp-c-default-soft: rgba(142, 150, 170, 0.1); - /* Aside / callout border radius (Apple uses 15px) */ + /* Aside */ --vp-custom-block-tip-border: rgba(26, 127, 63, 0.4); --vp-custom-block-tip-bg: rgba(26, 127, 63, 0.05); } @@ -59,10 +59,10 @@ --vp-c-text-2: #a1a1a6; --vp-c-text-3: #86868b; - --vp-c-brand-1: #6dd57e; - --vp-c-brand-2: #5ec76e; - --vp-c-brand-3: #30b8d0; - --vp-c-brand-soft: rgba(109, 213, 126, 0.12); + --vp-c-brand-1: #29d297; + --vp-c-brand-2: #24c48c; + --vp-c-brand-3: #1fb681; + --vp-c-brand-soft: rgba(41, 210, 151, 0.12); --vp-button-brand-bg: #6dd57e; --vp-button-brand-text: #1c1c1e; @@ -74,10 +74,24 @@ --vp-code-block-bg: #1c1c1e; } -/* Body text — Apple-style readability */ +/* Font rendering */ +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-synthesis: none; +} + +/* Body text */ +.vp-doc { + font-size: 1.0625rem; + line-height: 1.47; + letter-spacing: -0.022em; + font-weight: 400; +} + .vp-doc p, .vp-doc li { - line-height: 1.5294117647; + line-height: 1.5; letter-spacing: -0.022em; } @@ -87,13 +101,33 @@ letter-spacing: -0.01em; } -/* Code blocks — Apple dev docs rounded style */ +/* Code blocks */ .vp-doc div[class*="language-"] { border-radius: 15px; + margin-left: 0; + margin-right: 0; +} + +@media (max-width: 640px) { + .vp-doc div[class*="language-"] { + margin-left: -24px; + margin-right: -24px; + border-radius: 0; + } } +.vp-doc div[class*="language-"] code { + white-space: pre-wrap; + word-break: break-word; +} + +/* Inline code */ .vp-doc :not(pre) > code { - border-radius: 4px; + background-color: var(--vp-c-default-soft); + border-radius: 3px; + padding: 0.15em 0.35em; + font-size: 0.9em; + color: inherit; } /* Hero */ @@ -119,43 +153,112 @@ color: var(--vp-c-text-2); } -/* Feature cards — Apple card style */ -.VPFeature { - border-radius: 18px; +.VPHero .main::after { + content: "Requires macOS 26+ on Apple Silicon with Apple Intelligence enabled."; + display: block; + margin-top: 1.25rem; + font-size: 0.8125rem; + color: var(--vp-c-text-3); + letter-spacing: -0.01em; +} + +/* Reduce default home page bottom padding */ +.VPHome { + padding-bottom: 0 !important; + margin-bottom: 0 !important; +} + +/* Explore cards — prominent section links */ +.home-explore { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + max-width: 1152px; + margin: 0 auto; + padding: 2rem 2rem 1rem; +} + +.home-explore-card { + display: flex; + flex-direction: column; + padding: 1.25rem; + border-radius: 15px; + background: var(--vp-c-bg-soft); + text-decoration: none !important; transition: - box-shadow 0.16s ease-out, - transform 0.16s ease-out; + background-color 0.2s ease, + box-shadow 0.2s ease; } -.VPFeature:hover { - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06); - transform: scale(1.007); +.home-explore-card:hover { + background: var(--vp-c-bg-mute); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); } -.dark .VPFeature:hover { - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); +.dark .home-explore-card:hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); } -@media (prefers-reduced-motion: reduce) { - .VPFeature:hover { - transform: none; - } +.home-explore-title { + font-size: 1rem; + font-weight: 600; + color: var(--vp-c-text-1); + margin-bottom: 0.25rem; +} + +.home-explore-card:hover .home-explore-title { + color: var(--vp-c-brand-1); } -/* Nav — Apple-style blur when sticking */ -.VPNav { - backdrop-filter: saturate(180%) blur(20px); - -webkit-backdrop-filter: saturate(180%) blur(20px); +.home-explore-desc { + font-size: 0.8125rem; + color: var(--vp-c-text-2); + line-height: 1.4; } -/* Buttons — Apple border radius */ +@media (max-width: 640px) { + .home-explore { + grid-template-columns: 1fr; + } +} + +/* Buttons */ .VPButton { border-radius: 18px !important; } -/* Sidebar — tighter, cleaner */ -.VPSidebar .group + .group { - padding-top: 0.5rem; +/* Tables — subtler alternating rows */ +.vp-doc tr:nth-child(2n) { + background-color: rgba(0, 0, 0, 0.02); +} + +.dark .vp-doc tr:nth-child(2n) { + background-color: rgba(255, 255, 255, 0.02); +} + +/* External link icon — tighter spacing */ +:is(.vp-doc a[href*="://"], .vp-doc a[target="_blank"]):not( + :is(.no-icon, svg a, :has(img, svg)) + )::after { + margin-left: 0; +} + +/* Swift equivalent info boxes */ +.vp-doc .custom-block.info { + background: none; + border: none; + padding: 0; + margin: -0.5rem 0 1rem; + font-size: 0.9rem; + color: var(--vp-c-text-3); +} + +.vp-doc .custom-block.info .custom-block-title { + display: none; +} + +.vp-doc .custom-block.info p { + margin: 0; } /* Smooth transitions */ diff --git a/docs/.vitepress/theme/env.d.ts b/docs/.vitepress/theme/env.d.ts new file mode 100644 index 0000000..d1a1609 --- /dev/null +++ b/docs/.vitepress/theme/env.d.ts @@ -0,0 +1,5 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent; + export default component; +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index c495bc1..0b923a9 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,4 +1,13 @@ import DefaultTheme from "vitepress/theme"; +import { h } from "vue"; +import HomeExplore from "./HomeExplore.vue"; import "./custom.css"; -export default DefaultTheme; +export default { + extends: DefaultTheme, + Layout() { + return h(DefaultTheme.Layout, null, { + "home-features-after": () => h(HomeExplore), + }); + }, +}; diff --git a/docs/api/chat.md b/docs/api/chat.md new file mode 100644 index 0000000..36c105c --- /dev/null +++ b/docs/api/chat.md @@ -0,0 +1,568 @@ +# Chat & Responses API Reference + +API reference for `tsfm-sdk/chat`. This module provides a compatibility layer with a Responses API and Chat Completions API backed by on-device Apple Intelligence. + +```ts +import Client, { Stream, ResponseStream, MODEL_DEFAULT } from "tsfm-sdk/chat"; +``` + +## Client + +Main client class. Provides Chat-style and Responses-style API interfaces backed by on-device Apple Intelligence. + +### Constructor + +```ts +const client = new Client(); +``` + +No arguments. No API key needed. + +### Properties + +| Property | Type | Description | +| --- | --- | --- | +| `responses` | `Responses` | Responses API endpoint | +| `chat.completions` | `Completions` | Chat Completions API endpoint | + +### Methods + +#### `close()` + +Releases the native model pointer. Call when you're done with the client. + +```ts +client.close(): void +``` + +--- + +## Responses API + +### Responses + +Accessed via `client.responses`. Similar to the modern Responses API interface used by OpenAI. + +#### `responses.create(params)` + +Creates a response. + +```ts +// Non-streaming +create(params: ResponseCreateParams & { stream?: false | null }): Promise + +// Streaming +create(params: ResponseCreateParams & { stream: true }): Promise +``` + +--- + +### ResponseCreateParams + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `input` | `string \| ResponseInputItem[]` | Yes | Text prompt or array of input items | +| `model` | `string` | No | Ignored. Always uses on-device model. | +| `instructions` | `string` | No | System instructions | +| `stream` | `boolean` | No | Enable streaming | +| `temperature` | `number` | No | Sampling temperature | +| `max_output_tokens` | `number` | No | Maximum response tokens | +| `top_p` | `number` | No | Probability threshold for sampling | +| `seed` | `number` | No | Random seed for reproducibility | +| `tools` | `FunctionTool[]` | No | Tool definitions | +| `tool_choice` | `string \| object` | No | Accepted but ignored | +| `text` | `ResponseTextConfig` | No | Structured output configuration | + +All other params (`previous_response_id`, `conversation`, `store`, `truncation`, `metadata`, `reasoning`, etc.) are accepted but ignored with a runtime warning. + +--- + +### Input Types + +#### ResponseInputItem + +```ts +type ResponseInputItem = EasyInputMessage | ResponseFunctionToolCall | FunctionCallOutput; +``` + +#### EasyInputMessage + +```ts +{ + role: "user" | "assistant" | "system" | "developer"; + content: string | ResponseInputContent[]; + type?: "message"; +} +``` + +#### ResponseFunctionToolCall + +Passed back to continue a conversation after a function call: + +```ts +{ + type: "function_call"; + name: string; + arguments: string; + call_id: string; + status?: "in_progress" | "completed" | "incomplete"; +} +``` + +#### FunctionCallOutput + +Provides the result of a function call: + +```ts +{ + type: "function_call_output"; + call_id: string; + output: string; +} +``` + +#### ResponseInputContent + +```ts +type ResponseInputContent = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url?: string } // not supported + | { type: "input_file"; file_data?: string }; // not supported +``` + +Only `input_text` is supported. Other types log a warning and are skipped. + +--- + +### Tool Types (Responses API) + +#### FunctionTool + +Flat format — `name` and `parameters` are top-level (not nested under `function`): + +```ts +{ + type: "function"; + name: string; + parameters: Record | null; // JSON Schema + description?: string; + strict?: boolean | null; +} +``` + +--- + +### Structured Output (Responses API) + +#### ResponseTextConfig + +```ts +{ format?: ResponseFormatConfig } +``` + +#### ResponseFormatConfig + +```ts +type ResponseFormatConfig = + | { type: "text" } + | { type: "json_object" } + | { + type: "json_schema"; + name: string; + schema: Record; + description?: string; + strict?: boolean | null; + }; +``` + +Only `json_schema` triggers constrained generation. + +--- + +### Response Object + +```ts +{ + id: string; // "resp_" + object: "response"; + created_at: number; // Unix timestamp (seconds) + model: string; // "SystemLanguageModel" + output: ResponseOutputItem[]; + output_text: string; // convenience: concatenated text from output messages + status: "completed" | "failed" | "incomplete"; + error: ResponseError | null; + incomplete_details: { reason?: "max_output_tokens" | "content_filter" } | null; + instructions: string | null; + metadata: Record | null; + temperature: number | null; + top_p: number | null; + max_output_tokens: number | null; + tool_choice: "none" | "auto" | "required" | { type: "function"; name: string }; + tools: FunctionTool[]; + parallel_tool_calls: boolean; + text: ResponseTextConfig; + truncation: "auto" | "disabled" | null; + usage: null; // not tracked +} +``` + +### ResponseOutputItem + +```ts +type ResponseOutputItem = ResponseOutputMessage | ResponseOutputFunctionToolCall; +``` + +### ResponseOutputMessage + +```ts +{ + id: string; + type: "message"; + role: "assistant"; + status: "completed" | "incomplete" | "in_progress"; + content: Array; +} +``` + +### ResponseOutputText + +```ts +{ type: "output_text"; text: string; annotations: unknown[] } +``` + +### ResponseOutputRefusal + +```ts +{ type: "refusal"; refusal: string } +``` + +### ResponseOutputFunctionToolCall + +```ts +{ + type: "function_call"; + id: string; + call_id: string; // use this in FunctionCallOutput + name: string; + arguments: string; // JSON string + status: "completed"; +} +``` + +--- + +### ResponseStream + +Async iterable wrapper for Responses API streaming events. + +```ts +class ResponseStream implements AsyncIterable +``` + +| Method | Description | +| --- | --- | +| `[Symbol.asyncIterator]()` | Iterate events with `for await...of` | +| `close()` | Eagerly release resources | +| `toReadableStream()` | Convert to Web `ReadableStream` | + +### ResponseStreamEvent + +Union of all event types. See [Streaming Events](#streaming-events-reference) for the full list. + +--- + +### Streaming Events Reference + +| Event type | Key fields | +| --- | --- | +| `response.created` | `response: Response` | +| `response.in_progress` | `response: Response` | +| `response.completed` | `response: Response` | +| `response.failed` | `response: Response` | +| `response.incomplete` | `response: Response` | +| `response.output_item.added` | `item: ResponseOutputItem`, `output_index` | +| `response.output_item.done` | `item: ResponseOutputItem`, `output_index` | +| `response.content_part.added` | `part`, `item_id`, `output_index`, `content_index` | +| `response.content_part.done` | `part`, `item_id`, `output_index`, `content_index` | +| `response.output_text.delta` | `delta: string`, `item_id`, `output_index`, `content_index` | +| `response.output_text.done` | `text: string`, `item_id`, `output_index`, `content_index` | +| `response.refusal.delta` | `delta: string`, `item_id` | +| `response.refusal.done` | `refusal: string`, `item_id` | +| `response.function_call_arguments.delta` | `delta: string`, `item_id`, `output_index` | +| `response.function_call_arguments.done` | `arguments: string`, `name`, `call_id`, `item_id` | + +All events include a `sequence_number` field. + +--- + +## Chat Completions API + +### Completions + +Accessed via `client.chat.completions`. + +#### `chat.completions.create(params)` + +Creates a chat completion. + +```ts +// Non-streaming +create(params: ChatCompletionCreateParams & { stream?: false | null }): Promise + +// Streaming +create(params: ChatCompletionCreateParams & { stream: true }): Promise +``` + +--- + +## ChatCompletionCreateParams + +Request parameters for `create()`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `messages` | `ChatCompletionMessageParam[]` | Yes | Conversation messages | +| `model` | `string` | No | Ignored. Always uses on-device model. | +| `stream` | `boolean` | No | Enable streaming | +| `temperature` | `number` | No | Sampling temperature | +| `max_tokens` | `number` | No | Maximum response tokens | +| `max_completion_tokens` | `number` | No | Same as `max_tokens` (takes priority) | +| `top_p` | `number` | No | Probability threshold for sampling | +| `seed` | `number` | No | Random seed for reproducibility | +| `tools` | `ChatCompletionTool[]` | No | Tool definitions | +| `response_format` | `ResponseFormat` | No | Output format constraint | + +All other Chat Completions parameters (`n`, `stop`, `logprobs`, `frequency_penalty`, `presence_penalty`, `logit_bias`, `tool_choice`, `parallel_tool_calls`, etc.) are accepted but ignored. A warning is logged at runtime for each unsupported parameter that has a non-null value. + +--- + +## Message Types + +### ChatCompletionMessageParam + +Union of all message types: + +```ts +type ChatCompletionMessageParam = + | ChatCompletionSystemMessageParam + | ChatCompletionDeveloperMessageParam + | ChatCompletionUserMessageParam + | ChatCompletionAssistantMessageParam + | ChatCompletionToolMessageParam; +``` + +### ChatCompletionSystemMessageParam + +```ts +{ role: "system"; content: string; name?: string } +``` + +### ChatCompletionDeveloperMessageParam + +```ts +{ role: "developer"; content: string; name?: string } +``` + +### ChatCompletionUserMessageParam + +```ts +{ role: "user"; content: string | ChatCompletionContentPart[]; name?: string } +``` + +### ChatCompletionAssistantMessageParam + +```ts +{ + role: "assistant"; + content?: string | null; + tool_calls?: ChatCompletionMessageToolCall[]; + refusal?: string | null; + name?: string; +} +``` + +### ChatCompletionToolMessageParam + +```ts +{ role: "tool"; content: string; tool_call_id: string } +``` + +### ChatCompletionContentPart + +```ts +type ChatCompletionContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + | { type: "input_audio"; input_audio: { data: string; format: string } } + | { type: "file"; file: { file_data: string; filename: string } } + | { type: "refusal"; refusal: string }; +``` + +Only `text` parts are supported. Other content types log a warning and are skipped. + +--- + +## Tool Types + +### ChatCompletionTool + +```ts +{ + type: "function"; + function: { + name: string; + description?: string; + parameters?: Record; // JSON Schema + strict?: boolean | null; + }; +} +``` + +### ChatCompletionMessageToolCall + +```ts +{ + id: string; // "call_" + type: "function"; + function: { + name: string; + arguments: string; // JSON string + }; +} +``` + +--- + +## Response Format + +### ResponseFormat + +```ts +type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { + type: "json_schema"; + json_schema: { + name: string; + description?: string; + schema?: Record; + strict?: boolean | null; + }; + }; +``` + +Only `json_schema` triggers constrained generation. `text` and `json_object` are treated as plain text generation. + +--- + +## Response Types + +### ChatCompletion + +```ts +{ + id: string; // "chatcmpl-" + object: "chat.completion"; + created: number; // Unix timestamp (seconds) + model: string; // "SystemLanguageModel" + choices: ChatCompletionChoice[]; + usage: null; + system_fingerprint: null; +} +``` + +### ChatCompletionChoice + +```ts +{ + index: number; + message: ChatCompletionMessage; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter"; +} +``` + +### ChatCompletionMessage + +```ts +{ + role: "assistant"; + content: string | null; + refusal: string | null; + tool_calls?: ChatCompletionMessageToolCall[]; +} +``` + +--- + +## Streaming Types + +### Stream + +Async iterable wrapper with resource cleanup. + +```ts +class Stream implements AsyncIterable +``` + +| Method | Description | +| --- | --- | +| `[Symbol.asyncIterator]()` | Iterate chunks with `for await...of` | +| `close()` | Eagerly release resources | +| `toReadableStream()` | Convert to Web `ReadableStream` | + +The stream auto-closes on iteration completion, `break`, or error. A `FinalizationRegistry` ensures cleanup if the stream is abandoned without being fully consumed. + +### ChatCompletionChunk + +```ts +{ + id: string; + object: "chat.completion.chunk"; + created: number; + model: string; + choices: ChatCompletionChunkChoice[]; + usage: null; + system_fingerprint: null; +} +``` + +### ChatCompletionChunkDelta + +```ts +{ + role?: "assistant"; + content?: string | null; + tool_calls?: Array<{ + index: number; + id?: string; + type?: "function"; + function?: { name?: string; arguments?: string }; + }>; + refusal?: string | null; +} +``` + +--- + +## Constants + +### MODEL_DEFAULT + +```ts +const MODEL_DEFAULT = "SystemLanguageModel"; +``` + +Placeholder model identifier for the on-device foundation model. It can be omitted since only one model is available. + +--- + +## CompatError + +Error class with an HTTP-style status code, thrown for `RateLimitedError` (status 429). + +```ts +class CompatError extends Error { + status: number; +} +``` diff --git a/docs/api/index.md b/docs/api/index.md index be9a9e0..8062edd 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -22,6 +22,18 @@ Complete reference for all public exports from `tsfm`. | [GeneratedContent](/api/generation-schema#generatedcontent) | Structured generation result | | [Errors](/api/errors) | Error hierarchy and error codes | +## Chat & Responses APIs + +| Export | Description | +| --- | --- | +| [Client](/api/chat) | Chat-style and Responses-style API client backed by on-device Apple Intelligence | + +```ts +import Client from "tsfm-sdk/chat"; +``` + +See the [Chat & Responses API reference](/api/chat) for full type documentation. + ## Installation ```ts @@ -34,4 +46,7 @@ import { Transcript, SamplingMode, } from "tsfm-sdk"; + +// Chat API compatible interface +import Client from "tsfm-sdk/chat"; ``` diff --git a/docs/examples/chat-api.md b/docs/examples/chat-api.md new file mode 100644 index 0000000..c262fa5 --- /dev/null +++ b/docs/examples/chat-api.md @@ -0,0 +1,301 @@ +# Chat & Responses APIs + +Examples using the Chat-style and Responses-style API interfaces at `tsfm-sdk/chat`. Both the Responses API and Chat Completions API are shown. + +## Responses API + +### Basic Text Generation + +```ts +import Client from "tsfm-sdk/chat"; + +const client = new Client(); + +// String input — the simplest form +const response = await client.responses.create({ + instructions: "You are a helpful assistant. Be concise.", + input: "What is the capital of France?", +}); + +console.log(response.output_text); +// "The capital of France is Paris." + +client.close(); +``` + +### Multi-turn Conversation + +```ts +const response = await client.responses.create({ + instructions: "You are a math tutor. Be concise.", + input: [ + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); + +console.log(response.output_text); +// "12" +``` + +### Streaming + +```ts +const stream = await client.responses.create({ + input: "Count from 1 to 5, one per line.", + stream: true, +}); + +for await (const event of stream) { + if (event.type === "response.output_text.delta") { + process.stdout.write(event.delta); + } +} +console.log(); +``` + +### Structured Output + +```ts +const response = await client.responses.create({ + input: "Extract: Alice is 28 years old and lives in Seattle", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, +}); + +const person = JSON.parse(response.output_text); +console.log(person); +// { name: "Alice", age: 28, city: "Seattle" } +``` + +### Tool Calling + +```ts +const tools = [ + { + type: "function" as const, + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, +]; + +// Step 1: Model decides to call a tool +const response = await client.responses.create({ + input: "What's the weather in Tokyo?", + tools, +}); + +const fc = response.output.find((item) => item.type === "function_call"); + +if (fc && fc.type === "function_call") { + console.log("Tool:", fc.name); + console.log("Args:", fc.arguments); + + // Step 2: Execute the tool and send results back + const result = JSON.stringify({ temp: 22, condition: "Sunny" }); + + const followUp = await client.responses.create({ + input: [ + { role: "user", content: "What's the weather in Tokyo?" }, + fc, // pass the function_call back + { type: "function_call_output", call_id: fc.call_id, output: result }, + ], + tools, + }); + + console.log(followUp.output_text); + // "It's currently 22°C and sunny in Tokyo." +} +``` + +### Generation Options + +```ts +const response = await client.responses.create({ + input: "Write a creative haiku", + temperature: 0.8, + max_output_tokens: 50, + seed: 42, +}); + +console.log(response.output_text); +``` + +### Handling Errors + +```ts +const response = await client.responses.create({ + input: "...", +}); + +if (response.status === "incomplete") { + console.log("Incomplete:", response.incomplete_details?.reason); + // "max_output_tokens" or "content_filter" +} + +// Check for refusals +for (const item of response.output) { + if (item.type === "message") { + for (const content of item.content) { + if (content.type === "refusal") { + console.log("Refused:", content.refusal); + } + } + } +} +``` + +--- + +## Chat Completions API + +### Chat: Basic Text Generation + +```ts +import Client from "tsfm-sdk/chat"; + +const client = new Client(); + +const response = await client.chat.completions.create({ + messages: [ + { role: "system", content: "You are a helpful assistant. Be concise." }, + { role: "user", content: "What is the capital of France?" }, + ], +}); + +console.log(response.choices[0].message.content); +// "The capital of France is Paris." + +client.close(); +``` + +### Chat: Multi-turn Conversation + +```ts +const response = await client.chat.completions.create({ + messages: [ + { role: "system", content: "You are a math tutor. Be concise." }, + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); + +console.log(response.choices[0].message.content); +// "12" +``` + +### Chat: Streaming + +```ts +const stream = await client.chat.completions.create({ + messages: [{ role: "user", content: "Count from 1 to 5, one per line." }], + stream: true, +}); + +for await (const chunk of stream) { + const delta = chunk.choices[0].delta.content; + if (delta) process.stdout.write(delta); +} +console.log(); +``` + +### Chat: Structured Output + +```ts +const response = await client.chat.completions.create({ + messages: [ + { role: "user", content: "Extract: Alice is 28 years old and lives in Seattle" }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, +}); + +const person = JSON.parse(response.choices[0].message.content!); +console.log(person); +// { name: "Alice", age: 28, city: "Seattle" } +``` + +### Chat: Tool Calling + +```ts +const tools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + }, +]; + +// Step 1: Model decides to call a tool +const response = await client.chat.completions.create({ + messages: [{ role: "user", content: "What's the weather in Tokyo?" }], + tools, +}); + +const choice = response.choices[0]; + +if (choice.finish_reason === "tool_calls" && choice.message.tool_calls) { + const call = choice.message.tool_calls[0]; + console.log("Tool:", call.function.name); + console.log("Args:", call.function.arguments); + + // Step 2: Execute the tool and send results back + const result = JSON.stringify({ temp: 22, condition: "Sunny" }); + + const followUp = await client.chat.completions.create({ + messages: [ + { role: "user", content: "What's the weather in Tokyo?" }, + { role: "assistant", content: null, tool_calls: [call] }, + { role: "tool", tool_call_id: call.id, content: result }, + ], + tools, + }); + + console.log(followUp.choices[0].message.content); + // "It's currently 22°C and sunny in Tokyo." +} +``` diff --git a/docs/examples/index.md b/docs/examples/index.md index 3efbf5f..bc79cc9 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -22,3 +22,4 @@ Requires macOS 26+ with Apple Intelligence enabled. | [Generation Options](/examples/generation-options) | Temperature, sampling, token limits | | [Transcripts](/examples/transcript) | Session history persistence | | [Content Tagging](/examples/content-tagging) | Content tagging use case | +| [Chat & Responses APIs](/examples/chat-api) | Chat-style and Responses-style API interface | diff --git a/docs/guide/chat-api.md b/docs/guide/chat-api.md new file mode 100644 index 0000000..94cfa32 --- /dev/null +++ b/docs/guide/chat-api.md @@ -0,0 +1,484 @@ +# Chat & Responses APIs + +TSFM ships Chat-style and Responses-style API interfaces at `tsfm-sdk/chat`. It supports both `responses.create()` and `chat.completions.create()` so you can swap in on-device Apple Intelligence with minimal code changes. + +```ts +import Client from "tsfm-sdk/chat"; + +const client = new Client(); + +// Responses API (recommended) +const response = await client.responses.create({ + model: "SystemLanguageModel", + instructions: "You are a helpful assistant.", + input: "What is the capital of France?", +}); +console.log(response.output_text); + +// Chat Completions API +const completion = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "What is the capital of France?" }, + ], +}); +console.log(completion.choices[0].message.content); + +client.close(); +``` + +If you've used the OpenAI Node SDK or similar APIs, the interface should feel familiar. The biggest difference is that the `model` param can be omitted or set to `"SystemLanguageModel"` + +## What TSFM Supports + +Both APIs support the same core capabilities: + +| Feature | Responses API | Chat Completions API | tsfm Support | +| --- | --- | --- | --- | +| Text generation | `input: "..."` | `messages: [...]` | Full | +| Multi-turn conversations | `input: [...]` (message array) | `messages: [...]` | Full | +| Streaming | `stream: true` | `stream: true` | Full | +| Structured output | `text: { format: { type: "json_schema" } }` | `response_format: { type: "json_schema" }` | Full | +| Tool calling | `tools: [{ type: "function", name, ... }]` | `tools: [{ type: "function", function: { name, ... } }]` | Full | +| `temperature`, `max_output_tokens` | `temperature`, `max_output_tokens` | `temperature`, `max_tokens` / `max_completion_tokens` | Full | +| `top_p`, `seed` | `top_p`, `seed` | `top_p`, `seed` | Full | +| Image/audio content | `input_image`, `input_file` | Image URLs | Not supported (warns) | +| `usage` / token counts | `usage` | `usage` | Always `null` | + +--- + +## Responses API + +The Responses-style API uses a `client.responses.create()` function with a simpler input model and richer output structure. + +### Basic Usage + +The simplest `responses.create()` call takes a string `input`: + +```ts +const response = await client.responses.create({ + input: "What is the capital of France?", +}); + +// Outputs are available on the response object +console.log(response.output_text); +``` + +### Instructions + +In the Responses API, system instructions are a top-level parameter rather than a message role: + +```ts +const response = await client.responses.create({ + instructions: "You are a concise math tutor.", + input: "What is 2 + 2?", +}); +``` + +### Multi-turn Conversations + +For multi-turn conversations with `responses.create()`, pass an array of input items: + +```ts +const response = await client.responses.create({ + instructions: "You are a math tutor.", + input: [ + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); +``` + +### Streaming + +Pass `stream: true` to `responses.create()` to get a `ResponseStream` of typed events: + +```ts +const stream = await client.responses.create({ + input: "Tell me a story", + stream: true, +}); + +for await (const event of stream) { + if (event.type === "response.output_text.delta") { + process.stdout.write(event.delta); + } +} +``` + +Key event types: + +| Event type | Description | +| --- | --- | +| `response.created` | Response object created | +| `response.in_progress` | Generation started | +| `response.output_item.added` | New output item (message or function call) | +| `response.output_text.delta` | Text token | +| `response.output_text.done` | Full text complete | +| `response.function_call_arguments.delta` | Function arguments chunk | +| `response.function_call_arguments.done` | Full function call complete | +| `response.output_item.done` | Output item complete | +| `response.completed` | Full response complete | +| `response.incomplete` | Generation stopped early | + +::: warning +When streaming structured output or tool calls, the full response is generated before any events are emitted. This is because Foundation Models uses constrained generation (a grammar that forces valid JSON), which cannot be interrupted mid-token. Plain text generation is the only mode that streams incrementally as tokens are produced. +::: + +### Structured Output + +The Responses API uses `text.format` with `type: "json_schema"` for structured output: + +```ts +const response = await client.responses.create({ + input: "Extract: Alice is 28 and lives in Seattle", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, +}); + +const person = JSON.parse(response.output_text); +// { name: "Alice", age: 28, city: "Seattle" } +``` + +### Tool Calling + +The Responses API uses a flat tool format with `name` and `parameters` at the top level (not nested under `function` like Chat Completions): + +```ts +const response = await client.responses.create({ + input: "What's the weather in Tokyo?", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + ], +}); + +// Check for function calls in the output +for (const item of response.output) { + if (item.type === "function_call") { + console.log(item.name); // "get_weather" + console.log(item.arguments); // '{"city":"Tokyo"}' + console.log(item.call_id); // "call_" — use this to send results back + } +} +``` + +### Sending Tool Results Back + +Send results using `function_call_output` input items. Pass back the original `function_call` item alongside its output: + +```ts +const fc = response.output.find((item) => item.type === "function_call")!; + +const followUp = await client.responses.create({ + input: [ + { role: "user", content: "What's the weather in Tokyo?" }, + fc, // pass the function_call back + { + type: "function_call_output", + call_id: fc.call_id, + output: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ], + tools: [/* same tools */], +}); + +console.log(followUp.output_text); +// "It's currently 22°C and sunny in Tokyo." +``` + +### Generation Options + +```ts +const response = await client.responses.create({ + input: "Write a creative haiku", + temperature: 0.8, + max_output_tokens: 50, + seed: 42, +}); +``` + +### Error Mapping + +| Native error | Responses API equivalent | +| --- | --- | +| `ExceededContextWindowSizeError` | `status: "incomplete"`, `incomplete_details.reason: "max_output_tokens"` | +| `RefusalError` | Output contains `{ type: "refusal", refusal: "..." }` | +| `GuardrailViolationError` | `status: "incomplete"`, `incomplete_details.reason: "content_filter"` | +| `RateLimitedError` | Thrown as error with status `429` | + +### Response Object + +```ts +{ + id: "resp_...", + object: "response", + created_at: 1710000000, + model: "SystemLanguageModel", + output: [ + { + id: "msg_...", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "...", annotations: [] }] + } + ], + output_text: "...", // convenience: concatenated text + status: "completed", // "completed" | "failed" | "incomplete" + error: null, + incomplete_details: null, // { reason: "max_output_tokens" | "content_filter" } + instructions: "...", + usage: null // not tracked +} +``` + +--- + +## Chat Completions API + +The Chat Completions API uses the classic `client.chat.completions.create()` interface. + +### Messages + +The Chat Completions API accepts all standard message roles: + +| Role | Behavior | +| --- | --- | +| `system` | Mapped to the session's `instructions`. Only the first system message becomes instructions — subsequent ones are treated as user messages with a `[System]` prefix. | +| `developer` | Same as `system`. | +| `user` | Mapped to a user transcript entry. The last user message becomes the prompt. | +| `assistant` | Mapped to a response transcript entry. Tool calls are preserved. | +| `tool` | Mapped to a user message formatted as `[Tool result for toolName]: content`. | + +#### Chat: Multi-turn Conversations + +Pass the full conversation history in the `messages` array. The client converts it to a native Foundation Models [transcript](/guide/transcripts) behind the scenes — each `create()` call builds a fresh session from the messages you provide. + +```ts +const response = await client.chat.completions.create({ + messages: [ + { role: "system", content: "You are a math tutor." }, + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); +``` + +### Chat: Streaming + +Pass `stream: true` to get an async iterable of `ChatCompletionChunk` objects: + +```ts +const stream = await client.chat.completions.create({ + messages: [{ role: "user", content: "Tell me a story" }], + stream: true, +}); + +for await (const chunk of stream) { + const delta = chunk.choices[0].delta.content; + if (delta) process.stdout.write(delta); +} +``` + +The `Stream` object supports: + +- **`for await...of`** — iterates chunks, auto-closes on completion or `break` +- **`stream.close()`** — eagerly release resources without finishing iteration +- **`stream.toReadableStream()`** — convert to a Web `ReadableStream` for HTTP responses + +::: warning +Structured output and tool call responses are buffered — the model must finish constrained generation before the response is emitted. Only plain text streams token-by-token. +::: + +### Chat: Structured Output + +Use `response_format` with `type: "json_schema"` to get guaranteed JSON output: + +```ts +const response = await client.chat.completions.create({ + messages: [{ role: "user", content: "Extract: Alice is 28 and lives in Seattle" }], + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, +}); + +const person = JSON.parse(response.choices[0].message.content!); +// { name: "Alice", age: 28, city: "Seattle" } +``` + +The JSON schema is converted to Apple's native generation schema format at runtime. The model uses constrained sampling to guarantee valid output — no retry or validation needed. + +### Chat: Tool Calling + +Define tools using the standard function tool format: + +```ts +const tools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + }, +]; + +const response = await client.chat.completions.create({ + messages: [{ role: "user", content: "What's the weather in Tokyo?" }], + tools, +}); +``` + +When the model decides to call a tool, the response has `finish_reason: "tool_calls"` and `message.tool_calls` contains the calls: + +```ts +const choice = response.choices[0]; +if (choice.finish_reason === "tool_calls" && choice.message.tool_calls) { + const call = choice.message.tool_calls[0]; + console.log(call.function.name); // "get_weather" + console.log(call.function.arguments); // '{"city":"Tokyo"}' +} +``` + +#### Chat: Sending Tool Results Back + +After executing the tool, send the result back with a follow-up request that includes the full conversation: + +```ts +const followUp = await client.chat.completions.create({ + messages: [ + { role: "user", content: "What's the weather in Tokyo?" }, + { role: "assistant", content: null, tool_calls: [call] }, + { + role: "tool", + tool_call_id: call.id, + content: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ], + tools, +}); + +console.log(followUp.choices[0].message.content); +// "It's currently 22°C and sunny in Tokyo." +``` + +::: info +Under the hood, tool calling uses structured output with a discriminated schema. The model chooses between `"text"` and `"tool_call"` as the first generated token, then fills in the tool name and arguments. Tools are suppressed when the last message is a tool result to prevent the model from re-calling the same tool. +::: + +### Chat: Generation Options + +| Param | Maps to | +| --- | --- | +| `temperature` | `GenerationOptions.temperature` | +| `max_tokens` / `max_completion_tokens` | `GenerationOptions.maximumResponseTokens` (`max_completion_tokens` takes priority) | +| `top_p` | `SamplingMode.random({ probabilityThreshold })` | +| `seed` | `SamplingMode.random({ seed })` | + +```ts +const response = await client.chat.completions.create({ + messages: [{ role: "user", content: "Say hello" }], + temperature: 0, + max_tokens: 50, + seed: 42, +}); +``` + +## Chat Completions Error Mapping + +| Native error | Chat Completions equivalent | +| --- | --- | +| `ExceededContextWindowSizeError` | `finish_reason: "length"` | +| `RefusalError` | `message.refusal` set, `content: null` | +| `GuardrailViolationError` | `finish_reason: "content_filter"` | +| `RateLimitedError` | Thrown as error with status `429` | + +## Chat Completions Response Format + +```ts +{ + id: "chatcmpl-...", // Unique ID + object: "chat.completion", // Or "chat.completion.chunk" for streaming + created: 1710000000, // Unix timestamp (seconds) + model: "SystemLanguageModel", + choices: [{ + index: 0, + message: { + role: "assistant", + content: "...", // null when tool_calls present + refusal: null, // Set on RefusalError + tool_calls: [...] // Present when finish_reason is "tool_calls" + }, + finish_reason: "stop" // "stop" | "length" | "tool_calls" | "content_filter" + }], + usage: null, // Not tracked + system_fingerprint: null +} +``` + +--- + +## Cleanup + +Call `client.close()` when you're done to release the native model pointer: + +```ts +const client = new Client(); +// ... use client ... +client.close(); +``` + +Each `create()` call manages its own session lifecycle internally — sessions are created from the messages array and disposed after the response completes (or after streaming finishes). + +## What's Next + +- [Structured Output](/guide/structured-output) — Schema-based generation with the native SDK +- [Tools](/guide/tools) — Native tool calling with the `Tool` class +- [Streaming](/guide/streaming) — Native streaming API +- [Error Handling](/guide/error-handling) — Full error reference diff --git a/docs/guide/error-handling.md b/docs/guide/error-handling.md index 9c36842..618d1be 100644 --- a/docs/guide/error-handling.md +++ b/docs/guide/error-handling.md @@ -1,6 +1,10 @@ # Error Handling -All SDK errors extend `FoundationModelsError`. Generation-specific errors extend `GenerationError`, which itself extends `FoundationModelsError`. +All SDK errors extend `FoundationModelsError`. Generation-specific errors extend `GenerationError`, which itself extends `FoundationModelsError`. TSFM also adds `ServiceCrashedError` and `ToolCallError`. + +::: info +The **Swift** equivalent is [`LanguageModelSession.GenerationError`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror). +::: ## Error Hierarchy @@ -8,6 +12,7 @@ All SDK errors extend `FoundationModelsError`. Generation-specific errors extend All errors inherit from `FoundationModelsError`. **GenerationError** — errors during generation: + - `ExceededContextWindowSizeError` - `AssetsUnavailableError` - `GuardrailViolationError` @@ -18,6 +23,7 @@ All errors inherit from `FoundationModelsError`. - `ConcurrentRequestsError` - `RefusalError` - `InvalidGenerationSchemaError` +- `ServiceCrashedError` **ToolCallError** — a tool's `call()` method threw ::: @@ -46,38 +52,59 @@ try { ## Error Reference -### `ExceededContextWindowSizeError` -Session history is too long. Start a new session or use a shorter transcript. +### ExceededContextWindowSizeError + +The session's accumulated context has exceeded the model's limit. All content (instructions, prompts, responses, tool schemas, tool calls, and tool output) share one context window. Long conversations or large tool outputs will eventually hit this. Dispose the session and start a new one, optionally seeding it with a trimmed [transcript](/guide/transcripts). Apple recommends splitting large tasks across multiple sessions. + +### AssetsUnavailableError + +The on-device model files haven't finished downloading. This typically happens right after enabling Apple Intelligence or after a macOS update. Call `model.waitUntilAvailable()` before creating a session — it will resolve once the assets are ready. + +### GuardrailViolationError + +The model's safety [guardrails](/guide/model-configuration#guardrails) flagged the prompt or the generated response. With `DEFAULT` guardrails, this means unsafe content was detected and blocked. With `PERMISSIVE_CONTENT_TRANSFORMATIONS`, you should see this less often as the model will attempt to transform content instead of rejecting it. Either way, you should attempt to catch this and surface a user-friendly message. + +### UnsupportedGuideError + +A `GenerationGuide` on one of your schema properties isn't supported by the current model version. This can happen if you use a guide that was introduced in a newer OS version than the user is running. Check your guide types against the [guides reference](/guide/structured-output#generation-guides). -### `AssetsUnavailableError` -Model assets haven't been downloaded yet. Call `waitUntilAvailable()` before creating a session. +### UnsupportedLanguageOrLocaleError -### `GuardrailViolationError` -Content policy was triggered by the prompt or response. +The system locale or the language of the prompt isn't supported by the on-device model. Foundation Models supports a subset of languages — this error means you've hit one it can't handle. -### `UnsupportedGuideError` -A generation guide on a schema property isn't supported by the model. +### DecodingFailureError -### `UnsupportedLanguageOrLocaleError` -The current language or locale isn't supported. +The model generated output during structured generation, but it couldn't be decoded into your schema. This can happen with complex or deeply nested schemas. Simplify the schema or add more descriptive property descriptions to guide the model. -### `DecodingFailureError` -Structured generation couldn't parse the model's output into the schema. +### RateLimitedError -### `RateLimitedError` -Too many requests in a short period. Wait and retry. +Too many requests to the on-device model in a short window. This is an OS-level rate limit, not a network API limit. Back off and retry after a short delay. -### `ConcurrentRequestsError` -The session is already processing a request. Await the current response or call `cancel()` first. +### ConcurrentRequestsError + +You called a generation method on a session that's already processing a request. The SDK serializes calls internally via `_enqueue()`, so you shouldn't normally hit this. If you do, check that you're `await`ing calls or use `session.isResponding` to check state before calling. + +### RefusalError + +The model declined to generate a response. This is distinct from `GuardrailViolationError` — refusal means the model chose not to answer (e.g., the prompt asks for something outside its capabilities), not that a content filter triggered. + +### InvalidGenerationSchemaError + +Your `GenerationSchema` is malformed or was rejected by the on-device model. Common causes: unsupported property types, conflicting guides, or schemas that are too complex for the model to constrain. Also thrown when the native layer returns a `ModelManagerError Code=1041` rejection. + +### ServiceCrashedError + +The Apple Intelligence background service (`generativeexperiencesd`) has crashed. This is an OS-level issue, not an SDK bug. The error message includes the restart command: + +```bash +launchctl kickstart -k gui/$(id -u)/com.apple.generativeexperiencesd +``` -### `RefusalError` -The model declined to answer the prompt. +After restarting the service, create a new session and retry. -### `InvalidGenerationSchemaError` -The `GenerationSchema` is malformed. Check property types and guides. +### ToolCallError -### `ToolCallError` -A tool's `call()` method threw an error. The original error is wrapped with the tool name for context. +Your tool's `call()` method threw during execution. The SDK wraps the original error with the tool name so you can identify which tool failed and why. Access the original error via `err.cause`. ## Catching All SDK Errors diff --git a/docs/guide/generation-options.md b/docs/guide/generation-options.md index 42d8417..ac63e0b 100644 --- a/docs/guide/generation-options.md +++ b/docs/guide/generation-options.md @@ -2,6 +2,10 @@ Control temperature, token limits, and sampling strategy for any generation method. +::: info +The **Swift** equivalent is Foundation Models' [`GenerationOptions`](https://developer.apple.com/documentation/foundationmodels/generationoptions). +::: + ## Usage Pass `options` as part of the second argument to any generation method: @@ -22,15 +26,21 @@ const reply = await session.respond("Write a haiku about rain", { | Option | Type | Description | | --- | --- | --- | -| `temperature` | `number` | Controls randomness. Higher values (e.g. 0.9) produce more varied output. | -| `maximumResponseTokens` | `number` | Maximum number of tokens in the response. | -| `sampling` | `SamplingMode` | Sampling strategy (see below). | +| `temperature` | `number` | Influences the confidence of the model's response. Higher values produce more varied output. Lower values produce more deterministic output. | +| `maximumResponseTokens` | `number` | Maximum tokens the model is allowed to produce. Enforcing a strict limit can lead to truncated or grammatically incorrect responses. | +| `sampling` | `SamplingMode` | Controls how the model picks tokens from its probability distribution (see below). | ## Sampling Modes -### Greedy +The model builds its response token by token. At each step it produces a probability distribution over its vocabulary. The sampling mode controls how a token is selected from that distribution. + +::: info +The **Swift** equivalent is Foundation Models' [`SamplingMode`](https://developer.apple.com/documentation/foundationmodels/generationoptions/samplingmode). +::: + +### Greedy (Most Deterministic) -Always picks the most likely token. Deterministic output. +Always chooses the most likely token. The same prompt should always produce the same output. ```ts SamplingMode.greedy() @@ -38,18 +48,18 @@ SamplingMode.greedy() ### Random -Sample from the token distribution with optional constraints: +Samples from a subset of likely tokens. You must choose **one** of `top` or `probabilityThreshold`, but not both. Either can be combined with `seed` for reproducibility: + +| Parameter | Description | +| --- | --- | +| `top` | Pick from the K most likely tokens (fixed set). Cannot be combined with `probabilityThreshold`. Maps to Apple's `random(top:seed:)`. | +| `probabilityThreshold` | Pick from the smallest set of tokens whose probabilities sum to this threshold. Cannot be combined with `top`. Maps to Apple's `random(probabilityThreshold:seed:)`. | +| `seed` | Random seed for reproducible output. Works with either constraint. | ```ts -// Top-K sampling with a seed for reproducibility +// Top-K: pick from the 50 most likely tokens SamplingMode.random({ top: 50, seed: 42 }) -// Probability threshold (nucleus/top-P) +// Top-P (nucleus): pick from the smallest set of tokens whose probabilities add up to 0.9 SamplingMode.random({ probabilityThreshold: 0.9 }) ``` - -| Parameter | Description | -| --- | --- | -| `top` | Only consider the top K most likely tokens | -| `seed` | Random seed for reproducible output | -| `probabilityThreshold` | Only consider tokens whose cumulative probability exceeds this threshold | diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9dcd5fb..a34a5c3 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,5 +1,11 @@ # Getting Started +TSFM gives Node.js applications access to Apple's on-device large language model through the on-device Foundation Models framework. It loads a pre-compiled dynamic library [via FFI](https://koffi.dev/), allowing it the same access as native Swift and ObjC applications. + +TSFM is **not** a browser library or a cloud API. TSFM requires Node.js ≥20 on an Apple Silicon Mac running macOS 26+ with Apple Intelligence enabled. No matter what your AI assistant tells you, TSFM **will not work** in browser client-side code, on Windows/Linux, on Intel Macs or on macs without Apple Intelligence installed. + +You might use TSFM for CLI tools, local dev tooling, Electron apps, automation scripts or small Mac-native services written in TypeScript. + ## Requirements - **macOS 26** (Tahoe) or later, Apple Silicon @@ -12,7 +18,7 @@ npm install tsfm-sdk ``` -A prebuilt `libFoundationModels.dylib` is bundled with the package — Xcode is not required. If your machine needs a different build, see [Building from Source](#building-from-source). +Xcode is not required to use this package. The NPM package ships with a prebuilt dylib for macOS 26.0+. If you know your machine requires a different dylib, see [Building from Source](#building-from-source). ## Quick Start @@ -36,22 +42,29 @@ model.dispose(); ## Key Concepts -1. **Model** — `SystemLanguageModel` represents the on-device model. Check availability before creating sessions. -2. **Session** — `LanguageModelSession` maintains conversation state. All generation methods go through a session. -3. **Dispose** — Native resources must be released by calling `dispose()` when you're done. +**Apple Intelligence** refers to Apple's suite of generative AI features (Siri, Writing Tools, Image Playground, and more). The **Foundation Models** framework exposes **SystemLanguageModel**, the **on-device** large language model at the core of Apple Intelligence that runs on Macs, iPhones and iPads with no network required. + +TSFM basically mirrors the Swift Foundation Models API (same class names, same method signatures, same concepts) with TypeScript translating the same actions to the same underlying model. For the most part, [Apple's own documentation](https://developer.apple.com/documentation/FoundationModels) will translate pretty directly. + +| SDK class | Role | +| --- | --- | +| `SystemLanguageModel` | Entry point. Wraps the native model pointer and gates availability before you create sessions. | +| `LanguageModelSession` | Holds conversation state. All generation (text, structured, streaming, tool use) goes through a session. | +| `.dispose()` or `Symbol.dispose` | Releases native resources. Required for any object that holds a C pointer. | -## What's Next +## Where To Go From Here - [Model Configuration](/guide/model-configuration) — Use cases, guardrails, availability - [Sessions](/guide/sessions) — Creating and using sessions - [Streaming](/guide/streaming) — Token-by-token response streaming -- [Structured Output](/guide/structured-output) — Typed generation with schemas +- [Structured Outputs](/guide/structured-output) — Typed generation with dictionary or JSON schemas - [Tools](/guide/tools) — Function calling - [Error Handling](/guide/error-handling) — Error types and recovery +- [Chat API Compatibility](/guide/chat-api) — Drop-in Chat API compatible interface ## Building from Source -If you need to rebuild the native library: +If you are working on TSFM as a developer, or need to rebuild the native library, run: ```bash git clone https://github.com/codybrom/tsfm.git @@ -59,4 +72,4 @@ cd tsfm npm run build ``` -This requires **Xcode 26+** to compile the Swift bridge. +Rebuilding from source requires **Xcode 26+** to compile the libFoundationModels.dylib Swift bridge. diff --git a/docs/guide/json-schema.md b/docs/guide/json-schema.md deleted file mode 100644 index 2a6cc58..0000000 --- a/docs/guide/json-schema.md +++ /dev/null @@ -1,40 +0,0 @@ -# JSON Schema - -If you prefer working with standard JSON Schema objects instead of the `GenerationSchema` builder, use `respondWithJsonSchema()`. - -## Usage - -```ts -import { LanguageModelSession } from "tsfm-sdk"; - -const session = new LanguageModelSession(); - -const content = await session.respondWithJsonSchema("Generate a person profile", { - type: "object", - properties: { - name: { type: "string", description: "Full name" }, - age: { type: "integer", description: "Age in years" }, - occupation: { type: "string", description: "Job title" }, - }, - required: ["name", "age", "occupation"], -}); - -console.log(content.toObject()); -// { name: "Ada Lovelace", age: 36, occupation: "Mathematician" } -``` - -## Extracting Results - -`respondWithJsonSchema()` returns a `GeneratedContent` object. Use `toObject()` to get the full result as a plain object: - -```ts -const person = content.toObject(); -console.log(person.name, person.age); -``` - -## When to Use - -- **`respondWithSchema()`** — When you want type-safe property access via `content.value(key)` and generation guides for fine-grained constraints. -- **`respondWithJsonSchema()`** — When you already have a JSON Schema definition or want a simpler interface with `content.toObject()`. - -Both methods produce constrained, structured output. The SDK converts standard JSON Schema to Apple's internal format automatically. diff --git a/docs/guide/model-configuration.md b/docs/guide/model-configuration.md index e085c5d..157f449 100644 --- a/docs/guide/model-configuration.md +++ b/docs/guide/model-configuration.md @@ -1,6 +1,10 @@ # Model Configuration -`SystemLanguageModel` is the entry point for accessing the on-device model. It wraps Apple's Foundation Models framework and provides availability checking before you create sessions. +`SystemLanguageModel` is the entry point for the on-device model. It wraps the native model pointer to gate availability before you create sessions. + +::: info +The **Swift** equivalent is [`SystemLanguageModel`](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel). +::: ## Creating a Model @@ -19,12 +23,39 @@ const model = new SystemLanguageModel({ Both options are optional and default to the values shown above. +## Guardrails + +Guardrails control how the model handles potentially unsafe content in prompts and responses. + +::: info +The **Swift** equivalent is [`SystemLanguageModel.Guardrails`](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/guardrails). +::: + +| Value | Description | +| --- | --- | +| `DEFAULT` | Blocks unsafe content in both prompts and responses. Use this for most applications. | +| `PERMISSIVE_CONTENT_TRANSFORMATIONS` | Allows transforming potentially unsafe text input into text responses. Use this when your app needs to process user-generated content that may contain sensitive material (e.g., content moderation tools, text rewriting). | + +```ts +const model = new SystemLanguageModel({ + guardrails: SystemLanguageModelGuardrails.PERMISSIVE_CONTENT_TRANSFORMATIONS, +}); +``` + +With `DEFAULT` guardrails, unsafe content may trigger a `GuardrailViolationError`. With `PERMISSIVE_CONTENT_TRANSFORMATIONS`, the model may attempt to transform the content instead of rejecting it outright. + ## Use Cases +Use cases hint to the model what kind of task you're performing. + +::: info +The **Swift** equivalent is [`SystemLanguageModel.UseCase`](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel/usecase). +::: + | Value | Description | | --- | --- | | `GENERAL` | General-purpose text generation (default) | -| `CONTENT_TAGGING` | Optimized for classification and labeling | +| `CONTENT_TAGGING` | Optimized for classification and labeling tasks | ```ts const tagger = new SystemLanguageModel({ @@ -34,7 +65,7 @@ const tagger = new SystemLanguageModel({ ## Checking Availability -The model may not be available if Apple Intelligence is disabled, assets haven't been downloaded, or the device doesn't support it. +The on-device model may not be available if Apple Intelligence is disabled, assets haven't finished downloading, or the hardware doesn't support it. Always check before creating a session. ### Synchronous Check @@ -47,7 +78,7 @@ if (!available) { ### Waiting for Availability -`waitUntilAvailable()` polls until the model is ready (default 30 seconds): +`waitUntilAvailable()` polls until the model is ready, with a default timeout of 30 seconds. If the failure is permanent (`DEVICE_NOT_ELIGIBLE` or `APPLE_INTELLIGENCE_NOT_ENABLED`), it returns immediately rather than waiting the full timeout. It only retries when the reason is `MODEL_NOT_READY`. ```ts const { available } = await model.waitUntilAvailable(); diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index 9625633..b316cfe 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -1,6 +1,10 @@ # Sessions -`LanguageModelSession` manages conversation state and provides all generation methods. Each session maintains its own context window and transcript. +`LanguageModelSession` manages conversation state and provides all generation methods. Each session maintains its own context window and [transcript](/guide/transcripts). + +::: info +The **Swift** equivalent is [`LanguageModelSession`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession). +::: ## Creating a Session @@ -51,7 +55,7 @@ See [Generation Options](/guide/generation-options) for all available options. ## Concurrency -Sessions serialize concurrent calls automatically. If you call `respond()` while another request is in progress, it queues up and runs after the first completes: +Sessions serialize concurrent calls automatically. If you call `respond()` while another request is in progress, it queues and runs after the first completes: ```ts // These run sequentially, not in parallel @@ -72,6 +76,16 @@ session.cancel(); Cancellation is advisory — the response may still complete if the model finishes before the cancel is processed. After cancellation, the session resets to idle and is ready for new requests. +## Checking State + +`isResponding` tells you whether the session is currently processing a request: + +```ts +if (session.isResponding) { + // A generation call is in flight +} +``` + ## Cleanup Always dispose sessions when done to release native memory: @@ -79,3 +93,7 @@ Always dispose sessions when done to release native memory: ```ts session.dispose(); ``` + +::: tip +If you prefer a higher-level interface, the [Chat API compatibility layer](/guide/chat-api) manages sessions automatically behind a more standard `chat.completions.create()` interface. +::: diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index 0ec8c2f..ee6271c 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -1,6 +1,10 @@ # Streaming -Stream responses token-by-token using an async iterator. This is useful for displaying results as they're generated. +TSFM can stream responses token-by-token using an async iterator. The on-device model produces cumulative snapshots, and the SDK diffs them internally so you receive only the new tokens on each iteration. + +::: info +The **Swift** equivalent is [`LanguageModelSession.ResponseStream`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/responsestream). +::: ## Basic Streaming @@ -17,7 +21,7 @@ console.log(); session.dispose(); ``` -Each `chunk` is a string containing only the **new** tokens since the last iteration. The SDK handles diffing Apple's cumulative snapshots internally. +Each `chunk` is a string containing only the **new** tokens since the last iteration. ## With Options @@ -42,13 +46,26 @@ for await (const chunk of session.streamResponse("Explain TypeScript")) { console.log("\n\nFull response length:", full.length); ``` -## How It Works +## Chat API Streaming + +If you prefer the Chat API streaming interface, the [compatibility layer](/guide/chat-api#streaming) provides `stream: true` with `ChatCompletionChunk` objects: + +```ts +import Client from "tsfm-sdk/chat"; +const client = new Client(); -Under the hood, `streamResponse()`: +const stream = await client.chat.completions.create({ + messages: [{ role: "user", content: "Tell me a joke" }], + stream: true, +}); + +for await (const chunk of stream) { + const delta = chunk.choices[0].delta.content; + if (delta) process.stdout.write(delta); +} +client.close(); +``` -1. Creates a stream reference via the C bridge -2. Spawns a single Swift Task that yields cumulative snapshots -3. Diffs each snapshot against the previous to yield only new tokens -4. Releases the stream reference when iteration completes +## Cleanup -The async iterator keeps the Node.js event loop alive until streaming finishes. +The stream reference is released automatically when iteration completes or the session is disposed. The SDK keeps the Node.js event loop alive while streaming, so the process won't exit mid-stream. diff --git a/docs/guide/structured-output.md b/docs/guide/structured-output.md index fd081df..0f9f868 100644 --- a/docs/guide/structured-output.md +++ b/docs/guide/structured-output.md @@ -1,8 +1,16 @@ # Structured Output -Generate typed, constrained output using `GenerationSchema`. The model's response is guided to match your schema exactly. +When you provide a schema, the on-device model uses constrained sampling to guarantee its output matches your types and structure with no string parsing needed. -## Defining a Schema +::: info +The **Swift** equivalents are the [`@Generable`](https://developer.apple.com/documentation/foundationmodels/generable) macro for compile-time schemas and [`DynamicGenerationSchema`](https://developer.apple.com/documentation/foundationmodels/dynamicgenerationschema) for runtime schemas. TSFM's `GenerationSchema` maps to the same underlying dictionary format. +::: + +If you already have or prefer to use JSON Schema objects, you can use `respondWithJsonSchema` instead and the SDK will convert it at runtime. + +If you're unsure which schema format you should use, see [Picking a Schema Format](#picking-a-schema-format). + +## Defining a Schema (Native Format) ```ts import { GenerationSchema, GenerationGuide } from "tsfm-sdk"; @@ -25,7 +33,11 @@ const schema = new GenerationSchema("Person", "A person profile") ## Generation Guides -Guides constrain the model's output for a property: +Guides constrain the model's output for a property. + +::: info +The **Swift** equivalent is Foundation Models' [`@Guide`](https://developer.apple.com/documentation/foundationmodels/guide()) annotations. See Apple's [Generating Swift Data Structures with Guided Generation](https://developer.apple.com/documentation/foundationmodels/generating-swift-data-structures-with-guided-generation) guide. +::: | Method | Constrains | | --- | --- | @@ -81,3 +93,38 @@ const cat: Cat = { breed: content.value("breed"), }; ``` + +## Generating Structured Output with JSON Schema + +If you already have a JSON Schema definition, or are porting from OpenAI or another API, you can pass it directly with respondWithJsonSchema instead of building a GenerationSchema first: + +```ts +const content = await session.respondWithJsonSchema("Generate a person profile", { + type: "object", + properties: { + name: { type: "string", description: "Full name" }, + age: { type: "integer", description: "Age in years" }, + occupation: { type: "string", description: "Job title" }, + }, + required: ["name", "age", "occupation"], +}); + +const person = content.toObject(); +// { name: "Ada Lovelace", age: 36, occupation: "Mathematician" } +``` + +The SDK converts JSON Schema to Apple's native format automatically. Use toObject to get the full result as a plain object instead of extracting properties individually. + +## Picking a Schema Format + +Both methods are capable of producing constrained output. The choice comes down to whether you need [generation guides](#generation-guides) and what format you already have. + +Use **`respondWithSchema`** when you need the extra constraints possible only with generation guides. It takes a `GenerationSchema` built with TSFM, which is the native [dictionary](https://developer.apple.com/documentation/swift/dictionary) format that Foundation Models uses internally. This option is also the only path that supports [generation guides](#generation-guides). Guides like `constant`, `anyOf`, and `element` have no JSON Schema equivalent. Generation guides allow you to constrain token selection at generation time rather than validating output after. + +Use **`respondWithJsonSchema`** when you already have existing JSON schemas or don't need the extra constraints possible with generation guides. It accepts a standard JSON Schema object and TSFM converts it to the model's dictionary format at runtime. Standard constraints like `enum`, `minimum`/`maximum`, and `pattern` all work, but the model's more specific generation guides aren't available if you pass a JSON Schema. + +If you don't need guides, either works. If you already have JSON schemas or are porting from another API, `respondWithJsonSchema` is the faster path. + +::: tip +The [Chat API compatibility layer](/guide/chat-api#structured-output) also supports structured output via `response_format: { type: "json_schema" }`, using the same JSON Schema format as the Chat Completions API. +::: diff --git a/docs/guide/tools.md b/docs/guide/tools.md index e5818db..bdb157d 100644 --- a/docs/guide/tools.md +++ b/docs/guide/tools.md @@ -1,6 +1,10 @@ # Tools -Tools let the model call functions during generation. Define a tool with a name, description, argument schema, and handler — the model decides when to call it. +Tools let the model call your functions during generation. It is up to the model to decide if a tool can help, generate arguments matching your schema, call the tool, receive the result and continue generating. + +::: info +The **Swift** equivalent is the Foundation Models [`Tool`](https://developer.apple.com/documentation/foundationmodels/tool) protocol. +::: ## Defining a Tool @@ -76,3 +80,19 @@ tool.dispose(); ``` Tools can be reused across sessions — just dispose after all sessions are done. + +## Best Practices + +The Foundation Model [`Tool` documentation](https://developer.apple.com/documentation/foundationmodels/tool) recommends: + +- **Limit to 3–5 tools per session.** Tool schemas and descriptions consume context window space. More tools means less room for conversation. If your session exceeds the context size, split work across new sessions. +- **Keep descriptions short.** A brief phrase is enough. Long descriptions add latency and use up context. +- **Pre-run essential tools.** If a tool's output is always needed, call it yourself and include the result in the prompt or instructions rather than waiting for the model to discover it needs the tool. + +## Tool Chaining + +The model can call multiple tools in sequence within a single `respond()` call. If the first tool's output informs a second tool call, the model handles the chaining automatically — you don't need to loop. + +## Chat API Tool Calling + +If you prefer the Chat API tool calling interface, the [compatibility layer](/guide/chat-api#tool-calling) supports `tools` with the standard `ChatCompletionTool` format. You define tools as JSON objects instead of extending the `Tool` class, and handle tool execution yourself between requests. diff --git a/docs/guide/transcripts.md b/docs/guide/transcripts.md index ca95773..158e3a0 100644 --- a/docs/guide/transcripts.md +++ b/docs/guide/transcripts.md @@ -1,6 +1,54 @@ # Transcripts -Transcripts let you save and restore session history. This enables persistent conversations across process restarts. +Transcripts let you save and restore session history, enabling persistent conversations across process restarts. The transcript records instructions, user prompts, responses and tool results as a linear history. + +::: info +The **Swift** equivalent is Foundation Models' [`Transcript`](https://developer.apple.com/documentation/foundationmodels/transcript). +::: + +## Entry Types + +A transcript is a linear sequence of entries. + +::: info +The **Swift** equivalent is [`Transcript.Entry`](https://developer.apple.com/documentation/foundationmodels/transcript). +::: + +| Role | Description | +| --- | --- | +| `instructions` | Behavioral directives provided to the model when creating the session. | +| `user` | User input passed to `respond()` or `streamResponse()`. | +| `response` | Model-generated output (text, structured content, or tool calls). | +| `tool` | Results returned from executed tools. | + +## Inspecting Entries + +Use `entries()` to access typed transcript entries without manually parsing JSON: + +```ts +const entries = session.transcript.entries(); + +for (const entry of entries) { + if (entry.role === "response" && entry.contents) { + for (const content of entry.contents) { + if (content.type === "text") console.log(content.text); + } + } +} +``` + +Each entry has a `role` (`"instructions"`, `"user"`, `"response"`, or `"tool"`) and role-specific fields: + +| Field | Roles | Description | +| --- | --- | --- | +| `contents` | all | Array of text or structured content items. | +| `tools` | `instructions` | Tool definitions registered with the session. | +| `options` | `user` | Generation options for this prompt. | +| `responseFormat` | `user` | Schema constraint for structured output. | +| `toolCalls` | `response` | Tool invocations with name and arguments. | +| `assets` | `response` | Asset references in the response. | +| `toolName` | `tool` | Name of the tool that produced this output. | +| `toolCallID` | `tool` | ID linking this output to its tool call. | ## Exporting a Transcript @@ -58,5 +106,5 @@ resumed.dispose(); ``` ::: warning -Access `session.transcript` before calling `session.dispose()`. The transcript reads from the native session pointer, which is freed on dispose. +You must access `session.transcript` *before* calling `session.dispose()`. Transcripts are read from the native session pointer and will be lost when dispose runs. ::: diff --git a/docs/index.md b/docs/index.md index a95ff18..1b628ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,18 +15,28 @@ hero: - theme: alt text: View on GitHub link: https://github.com/codybrom/tsfm + features: - - icon: 🔒 - title: On-Device Inference - details: Runs entirely on Apple Silicon. Your data never leaves the machine — no network requests, no API keys. - icon: ⚡ - title: Streaming Generation - details: Async iterator interface for token-by-token streaming. Process responses as they're generated. + title: Streaming + details: "Get responses token-by-token as the model generates them." + link: /guide/streaming + linkText: Learn more - icon: 🧩 title: Structured Output - details: Typed schemas with generation guides constrain output to exactly the shape you need. + details: "Type-safe JSON generation using schemas and Apple's generation guide system." + link: /guide/structured-output + linkText: Learn more - icon: 🛠️ title: Tool Calling - details: Give the model tools to call during generation. Define schemas, implement handlers, get structured results. + details: "Define tools as classes, get structured invocations back from the model." + link: /guide/tools + linkText: Learn more + - icon: 🔌 + title: Chat & Responses APIs + details: "Use familiar Chat-style and Responses-style APIs. Change your imports, keep your code." + link: /guide/chat-api + linkText: Learn more + --- diff --git a/eslint.config.js b/eslint.config.js index 2933ea2..758f980 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,10 @@ export default tseslint.config( { rules: { "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], }, }, { diff --git a/examples/compat/openai-compat.ts b/examples/compat/openai-compat.ts new file mode 100644 index 0000000..c48867b --- /dev/null +++ b/examples/compat/openai-compat.ts @@ -0,0 +1,156 @@ +/** + * Side-by-side example: tsfm-sdk Chat API compatibility layer + * + * Run: npx tsx examples/compat/openai-compat.ts + * + * The only differences from openai-real.ts: + * 1. Import from "../../src/compat/index.js" instead of "openai" + * 2. No model parameter needed (uses on-device Apple Intelligence) + * 3. No API key needed + */ +import Client from "../../src/compat/index.js"; + +const client = new Client(); + +// --------------------------------------------------------------------------- +// 1. Basic text generation +// --------------------------------------------------------------------------- +console.log("=== Basic text generation ==="); +const basic = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [ + { role: "system", content: "You are a helpful assistant. Be concise." }, + { role: "user", content: "What is the capital of France?" }, + ], +}); +console.log("Response:", basic.choices[0].message.content); +console.log("Finish reason:", basic.choices[0].finish_reason); +console.log(); + +// --------------------------------------------------------------------------- +// 2. Multi-turn conversation +// --------------------------------------------------------------------------- +console.log("=== Multi-turn conversation ==="); +const multi = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [ + { role: "system", content: "You are a math tutor. Be concise." }, + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); +console.log("Response:", multi.choices[0].message.content); +console.log(); + +// --------------------------------------------------------------------------- +// 3. Streaming +// --------------------------------------------------------------------------- +console.log("=== Streaming ==="); +const stream = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [{ role: "user", content: "Count from 1 to 5, one per line." }], + stream: true, +}); +process.stdout.write("Response: "); +for await (const chunk of stream) { + const delta = chunk.choices[0].delta.content; + if (delta) process.stdout.write(delta); +} +console.log("\n"); + +// --------------------------------------------------------------------------- +// 4. Structured output (JSON schema) +// --------------------------------------------------------------------------- +console.log("=== Structured output ==="); +const structured = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [{ role: "user", content: "Extract: Alice is 28 years old and lives in Seattle" }], + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, +}); +console.log("Response:", structured.choices[0].message.content); +console.log("Parsed:", JSON.parse(structured.choices[0].message.content!)); +console.log(); + +// --------------------------------------------------------------------------- +// 5. Tool calling +// --------------------------------------------------------------------------- +console.log("=== Tool calling ==="); +const tools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + }, +]; + +const toolCall = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [{ role: "user", content: "What's the weather in Tokyo?" }], + tools, +}); + +const choice = toolCall.choices[0]; +if (choice.finish_reason === "tool_calls" && choice.message.tool_calls) { + const call = choice.message.tool_calls[0]; + console.log("Tool called:", call.function.name); + console.log("Arguments:", call.function.arguments); + + // Simulate tool result and continue + const followUp = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [ + { role: "user", content: "What's the weather in Tokyo?" }, + { role: "assistant", content: null, tool_calls: [call] }, + { + role: "tool", + tool_call_id: call.id, + content: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ], + tools, + }); + console.log("Follow-up:", followUp.choices[0].message.content); +} else { + console.log("Response:", choice.message.content); +} +console.log(); + +// --------------------------------------------------------------------------- +// 6. Generation options +// --------------------------------------------------------------------------- +console.log("=== Generation options ==="); +const opts = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [{ role: "user", content: "Say hello" }], + temperature: 0, + max_tokens: 20, +}); +console.log("Response:", opts.choices[0].message.content); +console.log(); + +client.close(); +console.log("Done!"); diff --git a/examples/compat/openai-real.ts b/examples/compat/openai-real.ts new file mode 100644 index 0000000..bc78619 --- /dev/null +++ b/examples/compat/openai-real.ts @@ -0,0 +1,158 @@ +/** + * Side-by-side example: Real OpenAI SDK + * + * Requires OPENAI_API_KEY in .env.local (see .env.local.example) + * + * Run: npm run example -- openai-real + */ +import OpenAI from "openai"; + +const client = new OpenAI(); + +// --------------------------------------------------------------------------- +// 1. Basic text generation +// --------------------------------------------------------------------------- +console.log("=== Basic text generation ==="); +const basic = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "You are a helpful assistant. Be concise." }, + { role: "user", content: "What is the capital of France?" }, + ], +}); +console.log("Response:", basic.choices[0].message.content); +console.log("Finish reason:", basic.choices[0].finish_reason); +console.log(); + +// --------------------------------------------------------------------------- +// 2. Multi-turn conversation +// --------------------------------------------------------------------------- +console.log("=== Multi-turn conversation ==="); +const multi = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "You are a math tutor. Be concise." }, + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); +console.log("Response:", multi.choices[0].message.content); +console.log(); + +// --------------------------------------------------------------------------- +// 3. Streaming +// --------------------------------------------------------------------------- +console.log("=== Streaming ==="); +const stream = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Count from 1 to 5, one per line." }], + stream: true, +}); +process.stdout.write("Response: "); +for await (const chunk of stream) { + const delta = chunk.choices[0].delta.content; + if (delta) process.stdout.write(delta); +} +console.log("\n"); + +// --------------------------------------------------------------------------- +// 4. Structured output (JSON schema) +// --------------------------------------------------------------------------- +console.log("=== Structured output ==="); +const structured = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Extract: Alice is 28 years old and lives in Seattle" }], + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + additionalProperties: false, + }, + strict: true, + }, + }, +}); +console.log("Response:", structured.choices[0].message.content); +console.log("Parsed:", JSON.parse(structured.choices[0].message.content!)); +console.log(); + +// --------------------------------------------------------------------------- +// 5. Tool calling +// --------------------------------------------------------------------------- +console.log("=== Tool calling ==="); +const tools: OpenAI.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + }, +]; + +const toolCall = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "What's the weather in Tokyo?" }], + tools, +}); + +const choice = toolCall.choices[0]; +if (choice.finish_reason === "tool_calls" && choice.message.tool_calls) { + const call = choice.message.tool_calls[0] as { + id: string; + type: "function"; + function: { name: string; arguments: string }; + }; + console.log("Tool called:", call.function.name); + console.log("Arguments:", call.function.arguments); + + // Simulate tool result and continue + const followUp = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { role: "user", content: "What's the weather in Tokyo?" }, + choice.message, + { + role: "tool", + tool_call_id: call.id, + content: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ], + tools, + }); + console.log("Follow-up:", followUp.choices[0].message.content); +} else { + console.log("Response:", choice.message.content); +} +console.log(); + +// --------------------------------------------------------------------------- +// 6. Generation options +// --------------------------------------------------------------------------- +console.log("=== Generation options ==="); +const opts = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Say hello" }], + temperature: 0, + max_tokens: 20, +}); +console.log("Response:", opts.choices[0].message.content); +console.log(); + +console.log("Done!"); diff --git a/examples/compat/responses-advanced-local.ts b/examples/compat/responses-advanced-local.ts new file mode 100644 index 0000000..b309a80 --- /dev/null +++ b/examples/compat/responses-advanced-local.ts @@ -0,0 +1,316 @@ +/** + * Advanced Responses API example: tsfm-sdk compat layer + * + * Pushes the Responses API interface further than the basic example: + * - Developer role messages + * - Content part arrays (input_text) + * - Nested JSON schemas + * - Multi-tool definitions + * - Chained tool calls (tool result → follow-up) + * - Streaming with tool calls + * - Temperature and seed control + * + * Run: npx tsx examples/compat/responses-advanced-local.ts + * + * The only differences from responses-advanced-real.ts: + * 1. Import from "../../src/compat/index.js" instead of "openai" + * 2. Model is "SystemLanguageModel" instead of "gpt-4o-mini" + * 3. No API key needed + */ +import Client from "../../src/compat/index.js"; + +const client = new Client(); +const MODEL = "SystemLanguageModel"; + +// --------------------------------------------------------------------------- +// 1. Developer role message (system-level instruction) +// --------------------------------------------------------------------------- +console.log("=== Developer role message ==="); +const dev = await client.responses.create({ + model: MODEL, + input: [ + { role: "developer", content: "You are a pirate. Always talk like a pirate." }, + { role: "user", content: "What is 2 + 2?" }, + ], +}); +console.log("Response:", dev.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 2. Content part arrays +// --------------------------------------------------------------------------- +console.log("=== Content part arrays ==="); +const parts = await client.responses.create({ + model: MODEL, + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Combine these two words into one: " }, + { type: "input_text", text: "sun and flower" }, + ], + }, + ], +}); +console.log("Response:", parts.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 3. Nested structured output schema +// --------------------------------------------------------------------------- +console.log("=== Nested structured output ==="); +const nested = await client.responses.create({ + model: MODEL, + input: + "Extract: The Acme Corp team has Alice (engineer, 28) and Bob (designer, 35). They are in Seattle.", + text: { + format: { + type: "json_schema", + name: "Team", + schema: { + type: "object", + properties: { + company: { type: "string" }, + location: { type: "string" }, + members: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + role: { type: "string" }, + age: { type: "integer" }, + }, + required: ["name", "role", "age"], + }, + }, + }, + required: ["company", "location", "members"], + }, + }, + }, +}); +console.log("Response:", nested.output_text); +const team = JSON.parse(nested.output_text); +console.log("Company:", team.company); +console.log("Members:", team.members.map((m: { name: string }) => m.name).join(", ")); +console.log(); + +// --------------------------------------------------------------------------- +// 4. Multi-tool definitions (model picks the right one) +// --------------------------------------------------------------------------- +console.log("=== Multi-tool selection ==="); +const tools = [ + { + type: "function" as const, + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + { + type: "function" as const, + name: "get_stock_price", + description: "Get current stock price for a ticker symbol", + parameters: { + type: "object", + properties: { + ticker: { type: "string", description: "Stock ticker symbol (e.g. AAPL)" }, + }, + required: ["ticker"], + }, + }, + { + type: "function" as const, + name: "translate", + description: "Translate text to another language", + parameters: { + type: "object", + properties: { + text: { type: "string", description: "Text to translate" }, + target_language: { type: "string", description: "Target language" }, + }, + required: ["text", "target_language"], + }, + }, +]; + +const multiTool = await client.responses.create({ + model: MODEL, + input: "What's the stock price of AAPL?", + tools, +}); + +const stockCall = multiTool.output.find((item) => item.type === "function_call"); +if (stockCall && stockCall.type === "function_call") { + console.log("Picked tool:", stockCall.name); + console.log("Arguments:", stockCall.arguments); +} else { + console.log("Response:", multiTool.output_text); +} +console.log(); + +// --------------------------------------------------------------------------- +// 5. Chained tool call → result → natural response +// --------------------------------------------------------------------------- +console.log("=== Chained tool call with result ==="); +const step1 = await client.responses.create({ + model: MODEL, + input: "Translate 'good morning' to Japanese", + tools, +}); + +const translateCall = step1.output.find((item) => item.type === "function_call"); +if (translateCall && translateCall.type === "function_call") { + console.log("Step 1 - Tool:", translateCall.name); + console.log("Step 1 - Args:", translateCall.arguments); + + // Feed the tool result back + const step2 = await client.responses.create({ + model: MODEL, + input: [ + { role: "user", content: "Translate 'good morning' to Japanese" }, + { + type: "function_call", + call_id: translateCall.call_id, + name: translateCall.name, + arguments: translateCall.arguments, + }, + { + type: "function_call_output", + call_id: translateCall.call_id, + output: JSON.stringify({ + translated_text: "おはようございます", + source: "en", + target: "ja", + }), + }, + ], + tools, + }); + console.log("Step 2 - Response:", step2.output_text); +} else { + console.log("Response:", step1.output_text); +} +console.log(); + +// --------------------------------------------------------------------------- +// 6. Streaming with tool calls +// --------------------------------------------------------------------------- +console.log("=== Streaming tool call ==="); +const streamTool = await client.responses.create({ + model: MODEL, + input: "What's the weather in Paris?", + tools, + stream: true, +}); + +for await (const event of streamTool) { + if (event.type === "response.output_item.done" && event.item.type === "function_call") { + console.log("Streamed tool:", event.item.name); + console.log("Streamed args:", event.item.arguments); + } +} +console.log(); + +// --------------------------------------------------------------------------- +// 7. Streaming structured output +// --------------------------------------------------------------------------- +console.log("=== Streaming structured output ==="); +const streamStructured = await client.responses.create({ + model: MODEL, + input: "Extract: Bob is 42 and lives in Portland", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, + stream: true, +}); + +let structuredText = ""; +for await (const event of streamStructured) { + if (event.type === "response.output_text.done") { + structuredText = event.text; + } +} +console.log("Result:", structuredText); +console.log("Parsed:", JSON.parse(structuredText)); +console.log(); + +// --------------------------------------------------------------------------- +// 8. Temperature control for determinism +// --------------------------------------------------------------------------- +console.log("=== Temperature 0 (deterministic) ==="); +const run1 = await client.responses.create({ + model: MODEL, + input: "What is the boiling point of water in Celsius? Reply with just the number.", + temperature: 0, +}); +const run2 = await client.responses.create({ + model: MODEL, + input: "What is the boiling point of water in Celsius? Reply with just the number.", + temperature: 0, +}); +console.log("Run 1:", run1.output_text); +console.log("Run 2:", run2.output_text); +console.log("Match:", run1.output_text === run2.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 9. Complex multi-turn with mixed item types +// --------------------------------------------------------------------------- +console.log("=== Complex multi-turn with tools in history ==="); +const complex = await client.responses.create({ + model: MODEL, + input: [ + { role: "developer", content: "You are a helpful travel assistant." }, + { role: "user", content: "What's the weather in Tokyo?" }, + { + type: "function_call", + call_id: "call_weather_1", + name: "get_weather", + arguments: '{"city":"Tokyo"}', + }, + { + type: "function_call_output", + call_id: "call_weather_1", + output: JSON.stringify({ temp: 22, condition: "Sunny", humidity: 65 }), + }, + { role: "assistant", content: "It's 22°C and sunny in Tokyo with 65% humidity." }, + { role: "user", content: "How about Paris?" }, + { + type: "function_call", + call_id: "call_weather_2", + name: "get_weather", + arguments: '{"city":"Paris"}', + }, + { + type: "function_call_output", + call_id: "call_weather_2", + output: JSON.stringify({ temp: 15, condition: "Cloudy", humidity: 80 }), + }, + { role: "assistant", content: "Paris is 15°C and cloudy with 80% humidity." }, + { role: "user", content: "Which city has better weather right now? Just the city name." }, + ], +}); +console.log("Response:", complex.output_text); +console.log(); + +client.close(); +console.log("Done!"); diff --git a/examples/compat/responses-advanced-real.ts b/examples/compat/responses-advanced-real.ts new file mode 100644 index 0000000..39e9c40 --- /dev/null +++ b/examples/compat/responses-advanced-real.ts @@ -0,0 +1,312 @@ +/** + * Advanced Responses API example: Real OpenAI SDK + * + * Pushes the Responses API interface further than the basic example: + * - Developer role messages + * - Content part arrays (input_text) + * - Nested JSON schemas + * - Multi-tool definitions + * - Chained tool calls (tool result → follow-up) + * - Streaming with tool calls + * - Temperature and seed control + * + * Requires OPENAI_API_KEY in .env.local + * + * Run: npx tsx --env-file=.env.local examples/compat/responses-advanced-real.ts + */ +import OpenAI from "openai"; + +const client = new OpenAI(); +const MODEL = "gpt-4o-mini"; + +// --------------------------------------------------------------------------- +// 1. Developer role message (system-level instruction) +// --------------------------------------------------------------------------- +console.log("=== Developer role message ==="); +const dev = await client.responses.create({ + model: MODEL, + input: [ + { role: "developer", content: "You are a pirate. Always talk like a pirate." }, + { role: "user", content: "What is 2 + 2?" }, + ], +}); +console.log("Response:", dev.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 2. Content part arrays +// --------------------------------------------------------------------------- +console.log("=== Content part arrays ==="); +const parts = await client.responses.create({ + model: MODEL, + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Combine these two words into one: " }, + { type: "input_text", text: "sun and flower" }, + ], + }, + ], +}); +console.log("Response:", parts.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 3. Nested structured output schema +// --------------------------------------------------------------------------- +console.log("=== Nested structured output ==="); +const nested = await client.responses.create({ + model: MODEL, + input: "Extract: The Acme Corp team has Alice (engineer, 28) and Bob (designer, 35). They are in Seattle.", + text: { + format: { + type: "json_schema", + name: "Team", + schema: { + type: "object", + properties: { + company: { type: "string" }, + location: { type: "string" }, + members: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + role: { type: "string" }, + age: { type: "integer" }, + }, + required: ["name", "role", "age"], + additionalProperties: false, + }, + }, + }, + required: ["company", "location", "members"], + additionalProperties: false, + }, + strict: true, + }, + }, +}); +console.log("Response:", nested.output_text); +const team = JSON.parse(nested.output_text); +console.log("Company:", team.company); +console.log("Members:", team.members.map((m: { name: string }) => m.name).join(", ")); +console.log(); + +// --------------------------------------------------------------------------- +// 4. Multi-tool definitions (model picks the right one) +// --------------------------------------------------------------------------- +console.log("=== Multi-tool selection ==="); +const tools = [ + { + type: "function" as const, + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + { + type: "function" as const, + name: "get_stock_price", + description: "Get current stock price for a ticker symbol", + parameters: { + type: "object", + properties: { + ticker: { type: "string", description: "Stock ticker symbol (e.g. AAPL)" }, + }, + required: ["ticker"], + }, + }, + { + type: "function" as const, + name: "translate", + description: "Translate text to another language", + parameters: { + type: "object", + properties: { + text: { type: "string", description: "Text to translate" }, + target_language: { type: "string", description: "Target language" }, + }, + required: ["text", "target_language"], + }, + }, +]; + +const multiTool = await client.responses.create({ + model: MODEL, + input: "What's the stock price of AAPL?", + tools, +}); + +const stockCall = multiTool.output.find((item) => item.type === "function_call"); +if (stockCall && stockCall.type === "function_call") { + console.log("Picked tool:", stockCall.name); + console.log("Arguments:", stockCall.arguments); +} else { + console.log("Response:", multiTool.output_text); +} +console.log(); + +// --------------------------------------------------------------------------- +// 5. Chained tool call → result → natural response +// --------------------------------------------------------------------------- +console.log("=== Chained tool call with result ==="); +const step1 = await client.responses.create({ + model: MODEL, + input: "Translate 'good morning' to Japanese", + tools, +}); + +const translateCall = step1.output.find((item) => item.type === "function_call"); +if (translateCall && translateCall.type === "function_call") { + console.log("Step 1 - Tool:", translateCall.name); + console.log("Step 1 - Args:", translateCall.arguments); + + // Feed the tool result back + const step2 = await client.responses.create({ + model: MODEL, + input: [ + { role: "user", content: "Translate 'good morning' to Japanese" }, + { + type: "function_call", + call_id: translateCall.call_id, + name: translateCall.name, + arguments: translateCall.arguments, + }, + { + type: "function_call_output", + call_id: translateCall.call_id, + output: JSON.stringify({ translated_text: "おはようございます", source: "en", target: "ja" }), + }, + ], + tools, + }); + console.log("Step 2 - Response:", step2.output_text); +} else { + console.log("Response:", step1.output_text); +} +console.log(); + +// --------------------------------------------------------------------------- +// 6. Streaming with tool calls +// --------------------------------------------------------------------------- +console.log("=== Streaming tool call ==="); +const streamTool = await client.responses.create({ + model: MODEL, + input: "What's the weather in Paris?", + tools, + stream: true, +}); + +for await (const event of streamTool) { + if (event.type === "response.output_item.done" && event.item.type === "function_call") { + console.log("Streamed tool:", event.item.name); + console.log("Streamed args:", event.item.arguments); + } +} +console.log(); + +// --------------------------------------------------------------------------- +// 7. Streaming structured output +// --------------------------------------------------------------------------- +console.log("=== Streaming structured output ==="); +const streamStructured = await client.responses.create({ + model: MODEL, + input: "Extract: Bob is 42 and lives in Portland", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + additionalProperties: false, + }, + strict: true, + }, + }, + stream: true, +}); + +let structuredText = ""; +for await (const event of streamStructured) { + if (event.type === "response.output_text.done") { + structuredText = event.text; + } +} +console.log("Result:", structuredText); +console.log("Parsed:", JSON.parse(structuredText)); +console.log(); + +// --------------------------------------------------------------------------- +// 8. Temperature control for determinism +// --------------------------------------------------------------------------- +console.log("=== Temperature 0 (deterministic) ==="); +const run1 = await client.responses.create({ + model: MODEL, + input: "What is the boiling point of water in Celsius? Reply with just the number.", + temperature: 0, +}); +const run2 = await client.responses.create({ + model: MODEL, + input: "What is the boiling point of water in Celsius? Reply with just the number.", + temperature: 0, +}); +console.log("Run 1:", run1.output_text); +console.log("Run 2:", run2.output_text); +console.log("Match:", run1.output_text === run2.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 9. Complex multi-turn with mixed item types +// --------------------------------------------------------------------------- +console.log("=== Complex multi-turn with tools in history ==="); +const complex = await client.responses.create({ + model: MODEL, + input: [ + { role: "developer", content: "You are a helpful travel assistant." }, + { role: "user", content: "What's the weather in Tokyo?" }, + { + type: "function_call", + call_id: "call_weather_1", + name: "get_weather", + arguments: '{"city":"Tokyo"}', + }, + { + type: "function_call_output", + call_id: "call_weather_1", + output: JSON.stringify({ temp: 22, condition: "Sunny", humidity: 65 }), + }, + { role: "assistant", content: "It's 22°C and sunny in Tokyo with 65% humidity." }, + { role: "user", content: "How about Paris?" }, + { + type: "function_call", + call_id: "call_weather_2", + name: "get_weather", + arguments: '{"city":"Paris"}', + }, + { + type: "function_call_output", + call_id: "call_weather_2", + output: JSON.stringify({ temp: 15, condition: "Cloudy", humidity: 80 }), + }, + { role: "assistant", content: "Paris is 15°C and cloudy with 80% humidity." }, + { role: "user", content: "Which city has better weather right now? Just the city name." }, + ], +}); +console.log("Response:", complex.output_text); +console.log(); + +console.log("Done!"); diff --git a/examples/compat/responses-local.ts b/examples/compat/responses-local.ts new file mode 100644 index 0000000..99187dc --- /dev/null +++ b/examples/compat/responses-local.ts @@ -0,0 +1,160 @@ +/** + * Responses API example: tsfm-sdk compat layer + * + * Run: npx tsx examples/compat/responses-local.ts + * + * The only differences from responses-real.ts: + * 1. Import from "../../src/compat/index.js" instead of "openai" + * 2. No model parameter needed (uses on-device Apple Intelligence) + * 3. No API key needed + */ +import Client from "../../src/compat/index.js"; + +const client = new Client(); + +// --------------------------------------------------------------------------- +// 1. Basic text generation +// --------------------------------------------------------------------------- +console.log("=== Basic text generation ==="); +const basic = await client.responses.create({ + model: "SystemLanguageModel", + input: "What is the capital of France? Be concise.", +}); +console.log("Response:", basic.output_text); +console.log("Status:", basic.status); +console.log(); + +// --------------------------------------------------------------------------- +// 2. Instructions +// --------------------------------------------------------------------------- +console.log("=== Instructions ==="); +const instr = await client.responses.create({ + model: "SystemLanguageModel", + instructions: "You are a math tutor. Be concise.", + input: "What is 2 + 2?", +}); +console.log("Response:", instr.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 3. Multi-turn conversation +// --------------------------------------------------------------------------- +console.log("=== Multi-turn conversation ==="); +const multi = await client.responses.create({ + model: "SystemLanguageModel", + input: [ + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); +console.log("Response:", multi.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 4. Streaming +// --------------------------------------------------------------------------- +console.log("=== Streaming ==="); +const stream = await client.responses.create({ + model: "SystemLanguageModel", + input: "Count from 1 to 5, one per line.", + stream: true, +}); +process.stdout.write("Response: "); +for await (const event of stream) { + if (event.type === "response.output_text.delta") { + process.stdout.write(event.delta); + } +} +console.log("\n"); + +// --------------------------------------------------------------------------- +// 5. Structured output +// --------------------------------------------------------------------------- +console.log("=== Structured output ==="); +const structured = await client.responses.create({ + model: "SystemLanguageModel", + input: "Extract: Alice is 28 years old and lives in Seattle", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + }, + }, + }, +}); +console.log("Response:", structured.output_text); +console.log("Parsed:", JSON.parse(structured.output_text)); +console.log(); + +// --------------------------------------------------------------------------- +// 6. Tool calling +// --------------------------------------------------------------------------- +console.log("=== Tool calling ==="); +const toolCall = await client.responses.create({ + model: "SystemLanguageModel", + input: "What's the weather in Tokyo?", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + ], +}); + +const fc = toolCall.output.find((item) => item.type === "function_call"); +if (fc && fc.type === "function_call") { + console.log("Tool called:", fc.name); + console.log("Arguments:", fc.arguments); + + // Send tool result back + const followUp = await client.responses.create({ + model: "SystemLanguageModel", + input: [ + { role: "user", content: "What's the weather in Tokyo?" }, + { type: "function_call", call_id: fc.call_id, name: fc.name, arguments: fc.arguments }, + { + type: "function_call_output", + call_id: fc.call_id, + output: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ], + tools: [ + { + type: "function", + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + ], + }); + console.log("Follow-up:", followUp.output_text); +} else { + console.log("Response:", toolCall.output_text); +} +console.log(); + +client.close(); +console.log("Done!"); diff --git a/examples/compat/responses-real.ts b/examples/compat/responses-real.ts new file mode 100644 index 0000000..581e78e --- /dev/null +++ b/examples/compat/responses-real.ts @@ -0,0 +1,158 @@ +/** + * Responses API example: Real OpenAI SDK + * + * Requires OPENAI_API_KEY in .env.local + * + * Run: npx tsx --env-file=.env.local examples/compat/responses-real.ts + */ +import OpenAI from "openai"; + +const client = new OpenAI(); + +// --------------------------------------------------------------------------- +// 1. Basic text generation +// --------------------------------------------------------------------------- +console.log("=== Basic text generation ==="); +const basic = await client.responses.create({ + model: "gpt-4o-mini", + input: "What is the capital of France? Be concise.", +}); +console.log("Response:", basic.output_text); +console.log("Status:", basic.status); +console.log(); + +// --------------------------------------------------------------------------- +// 2. Instructions +// --------------------------------------------------------------------------- +console.log("=== Instructions ==="); +const instr = await client.responses.create({ + model: "gpt-4o-mini", + instructions: "You are a math tutor. Be concise.", + input: "What is 2 + 2?", +}); +console.log("Response:", instr.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 3. Multi-turn conversation +// --------------------------------------------------------------------------- +console.log("=== Multi-turn conversation ==="); +const multi = await client.responses.create({ + model: "gpt-4o-mini", + input: [ + { role: "user", content: "What is 2 + 2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], +}); +console.log("Response:", multi.output_text); +console.log(); + +// --------------------------------------------------------------------------- +// 4. Streaming +// --------------------------------------------------------------------------- +console.log("=== Streaming ==="); +const stream = await client.responses.create({ + model: "gpt-4o-mini", + input: "Count from 1 to 5, one per line.", + stream: true, +}); +process.stdout.write("Response: "); +for await (const event of stream) { + if (event.type === "response.output_text.delta") { + process.stdout.write(event.delta); + } +} +console.log("\n"); + +// --------------------------------------------------------------------------- +// 5. Structured output +// --------------------------------------------------------------------------- +console.log("=== Structured output ==="); +const structured = await client.responses.create({ + model: "gpt-4o-mini", + input: "Extract: Alice is 28 years old and lives in Seattle", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + city: { type: "string" }, + }, + required: ["name", "age", "city"], + additionalProperties: false, + }, + strict: true, + }, + }, +}); +console.log("Response:", structured.output_text); +console.log("Parsed:", JSON.parse(structured.output_text)); +console.log(); + +// --------------------------------------------------------------------------- +// 6. Tool calling +// --------------------------------------------------------------------------- +console.log("=== Tool calling ==="); +const toolCall = await client.responses.create({ + model: "gpt-4o-mini", + input: "What's the weather in Tokyo?", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + ], +}); + +const fc = toolCall.output.find((item) => item.type === "function_call"); +if (fc && fc.type === "function_call") { + console.log("Tool called:", fc.name); + console.log("Arguments:", fc.arguments); + + // Send tool result back + const followUp = await client.responses.create({ + model: "gpt-4o-mini", + input: [ + { role: "user", content: "What's the weather in Tokyo?" }, + { type: "function_call", call_id: fc.call_id, name: fc.name, arguments: fc.arguments }, + { + type: "function_call_output", + call_id: fc.call_id, + output: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ], + tools: [ + { + type: "function", + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + ], + }); + console.log("Follow-up:", followUp.output_text); +} else { + console.log("Response:", toolCall.output_text); +} +console.log(); + +console.log("Done!"); diff --git a/package-lock.json b/package-lock.json index 333fd67..1821633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tsfm-sdk", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tsfm-sdk", - "version": "0.2.3", + "version": "0.3.0", "cpu": [ "arm64" ], @@ -21,6 +21,7 @@ "@types/node": "^22.0.0", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.4", + "openai": "^6.27.0", "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", @@ -2558,6 +2559,28 @@ ], "license": "MIT" }, + "node_modules/openai": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz", + "integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 8127c1b..3c28ab9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tsfm-sdk", - "version": "0.2.3", + "version": "0.3.0", "description": "Unofficial TypeScript bindings for Apple's Foundation Models framework (Apple Intelligence on-device LLM)", "license": "Apache-2.0", "type": "module", @@ -10,6 +10,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./chat": { + "import": "./dist/compat/index.js", + "types": "./dist/compat/index.d.ts" } }, "scripts": { @@ -22,8 +26,11 @@ "format:check": "prettier --check src/", "test": "vitest run", "test:watch": "vitest", - "test:unit": "vitest run tests/unit", - "test:integration": "vitest run --config tests/integration/vitest.config.ts" + "test:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:coverage": "vitest run --coverage", + "example": "tsx scripts/run-example.ts", + "typecheck": "tsc --noEmit" }, "files": [ "dist/", @@ -39,6 +46,7 @@ "@types/node": "^22.0.0", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.4", + "openai": "^6.27.0", "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/scripts/run-example.ts b/scripts/run-example.ts new file mode 100644 index 0000000..0f9f0f2 --- /dev/null +++ b/scripts/run-example.ts @@ -0,0 +1,55 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, basename } from "node:path"; +import { execFileSync } from "node:child_process"; + +function findExamples(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...findExamples(full)); + } else if (entry.endsWith(".ts")) { + results.push(full); + } + } + return results.sort(); +} + +const examples = findExamples("examples"); +const name = process.argv[2]; + +if (!name) { + console.log("Usage: npm run example -- \n"); + console.log("Available examples:"); + for (const f of examples) console.log(` ${basename(f, ".ts")}`); + console.log( + "\nNote: openai-real, responses-real, and responses-advanced-real require OPENAI_API_KEY in .env.local", + ); + console.log("See .env.local.example for setup."); + process.exit(1); +} + +if (name === "all") { + for (const f of examples) { + const label = basename(f, ".ts"); + console.log(`\n${"=".repeat(60)}`); + console.log(` ${label}`); + console.log(`${"=".repeat(60)}\n`); + try { + execFileSync("tsx", ["--env-file=.env.local", f], { stdio: "inherit" }); + } catch { + console.error(`\n[${label}] failed\n`); + } + } + process.exit(0); +} + +const match = examples.find((f) => basename(f, ".ts") === name); +if (!match) { + console.log(`Unknown example: ${name}\n`); + console.log("Available examples:"); + for (const f of examples) console.log(` ${basename(f, ".ts")}`); + process.exit(1); +} + +execFileSync("tsx", ["--env-file=.env.local", match], { stdio: "inherit" }); diff --git a/src/compat/index.ts b/src/compat/index.ts new file mode 100644 index 0000000..9ce55a5 --- /dev/null +++ b/src/compat/index.ts @@ -0,0 +1,385 @@ +import { randomUUID } from "node:crypto"; +import { SystemLanguageModel } from "../core.js"; +import { LanguageModelSession } from "../session.js"; +import { Transcript } from "../transcript.js"; +import type { JsonSchema, JsonObject } from "../schema.js"; +import type { GenerationOptions } from "../options.js"; +import { + ExceededContextWindowSizeError, + RefusalError, + RateLimitedError, + GuardrailViolationError, +} from "../errors.js"; +import { messagesToTranscript } from "./transcript.js"; +import { mapParams } from "./params.js"; +import { + buildToolInstructions, + buildToolSchema, + parseToolResponse, + type ToolModelOutput, +} from "./tools.js"; +import { Stream } from "./stream.js"; +import { Responses } from "./responses.js"; +import { reorderJson, nowSeconds, CompatError } from "./utils.js"; +import type { + ChatCompletionCreateParams, + ChatCompletion, + ChatCompletionChunk, + ChatCompletionTool, +} from "./types.js"; + +export { Stream } from "./stream.js"; +export { ResponseStream } from "./responses-stream.js"; +export { Responses } from "./responses.js"; +export * from "./types.js"; +export * from "./responses-types.js"; + +export const MODEL_DEFAULT = "SystemLanguageModel"; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface TranscriptJson { + type: string; + version: number; + transcript: { + entries: Array<{ + role: string; + id: string; + contents: Array<{ type: string; text: string; id: string }>; + options?: JsonObject; + }>; + }; +} + +function makeId(): string { + return "chatcmpl-" + randomUUID(); +} + +// --------------------------------------------------------------------------- +// Nested helper classes +// --------------------------------------------------------------------------- + +class Completions { + private _getModel: () => SystemLanguageModel; + + constructor(getModel: () => SystemLanguageModel) { + this._getModel = getModel; + } + + /** + * Create a chat completion, mirroring the `chat.completions.create()` API. + * + * Supported params are mapped to native GenerationOptions; unsupported params + * (e.g. `n`, `logprobs`, `tool_choice`) are warned and ignored. See + * `mapParams()` for the full mapping. Sessions are created and disposed + * automatically per call. + * + * Native errors are mapped to standard responses: + * - `ExceededContextWindowSizeError` → `finish_reason: "length"` + * - `GuardrailViolationError` → `finish_reason: "content_filter"` + * - `RefusalError` → `message.refusal` with `content: null` + * - `RateLimitedError` → thrown with `status: 429` + */ + async create(params: ChatCompletionCreateParams & { stream: true }): Promise; + async create( + params: ChatCompletionCreateParams & { stream?: false | null }, + ): Promise; + async create(params: ChatCompletionCreateParams): Promise; + async create(params: ChatCompletionCreateParams): Promise { + const options = mapParams(params); + const { transcriptJson, prompt: rawPrompt } = messagesToTranscript(params.messages); + let prompt = rawPrompt; + let transcriptStr = transcriptJson; + + // When the last message is a tool result, the tool already ran — the model + // should respond with plain text incorporating the result, not try to call + // tools again. Only enable tool-calling structured output for the initial + // request (last message is "user", not "tool"). + const lastMsg = params.messages[params.messages.length - 1]; + const tools = lastMsg.role === "tool" ? undefined : params.tools; + + // Inject tool instructions into the transcript's instructions entry + if (tools && tools.length > 0) { + const toolInstructions = buildToolInstructions(tools); + const parsed = JSON.parse(transcriptStr) as TranscriptJson; + const instructionsEntry = parsed.transcript.entries.find((e) => e.role === "instructions"); + if (instructionsEntry) { + instructionsEntry.contents[0].text += toolInstructions; + } else { + parsed.transcript.entries.unshift({ + role: "instructions", + id: randomUUID(), + contents: [{ type: "text", text: toolInstructions.trimStart(), id: randomUUID() }], + }); + } + transcriptStr = JSON.stringify(parsed); + } + + // Append JSON instruction to prompt for json_object mode + if (params.response_format?.type === "json_object") { + prompt += "\n\nRespond with valid JSON only. No other text."; + } + + // Remind the model to use tools when they're available + if (tools && tools.length > 0) { + prompt += "\n\nRemember: if a tool can help answer this, use type tool_call."; + } + + // Create session from transcript + const transcript = Transcript.fromJson(transcriptStr); + const model = this._getModel(); + const session = LanguageModelSession.fromTranscript(transcript, { model }); + + if (params.stream) { + return this._createStream(session, prompt, options, tools); + } + + return this._createCompletion(session, prompt, options, params, tools); + } + + private async _createCompletion( + session: LanguageModelSession, + prompt: string, + options: GenerationOptions, + params: ChatCompletionCreateParams, + tools?: ChatCompletionTool[], + ): Promise { + try { + // Tools present → use structured output with tool schema + if (tools && tools.length > 0) { + const schema = buildToolSchema(tools); + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + const parsed = JSON.parse(content.toJson()) as ToolModelOutput; + const result = parseToolResponse(parsed); + + if (result.type === "tool_call" && result.toolCall) { + return buildCompletion(null, "tool_calls", [result.toolCall]); + } + return buildCompletion(result.content as string, "stop"); + } + + // json_schema response format + if (params.response_format?.type === "json_schema") { + const rf = params.response_format as { + type: "json_schema"; + json_schema: { schema?: JsonSchema }; + }; + const schema = rf.json_schema.schema ?? { type: "object" }; + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + return buildCompletion(reorderJson(content.toJson(), schema), "stop"); + } + + // Plain text + const text = await session.respond(prompt, { options }); + return buildCompletion(text, "stop"); + } catch (err) { + if (err instanceof ExceededContextWindowSizeError) { + return buildCompletion("", "length"); + } + if (err instanceof RefusalError) { + return { + ...buildCompletion(null, "stop"), + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: null, + refusal: err.message, + }, + finish_reason: "stop" as const, + }, + ], + }; + } + if (err instanceof RateLimitedError) { + throw new CompatError(err.message, 429); + } + if (err instanceof GuardrailViolationError) { + return buildCompletion(null, "content_filter"); + } + throw err; + } finally { + session.dispose(); + } + } + + private _createStream( + session: LanguageModelSession, + prompt: string, + options: GenerationOptions, + tools?: ChatCompletionTool[], + ): Stream { + const id = makeId(); + const created = nowSeconds(); + + async function* generate(): AsyncGenerator { + try { + // First chunk: role announcement + yield makeChunk(id, created, { role: "assistant", content: "" }, null); + + // Tools or structured output with streaming: buffer the full response + if (tools && tools.length > 0) { + const schema = buildToolSchema(tools); + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + const parsed = JSON.parse(content.toJson()) as ToolModelOutput; + const result = parseToolResponse(parsed); + + if (result.type === "tool_call" && result.toolCall) { + yield makeChunk( + id, + created, + { + tool_calls: [ + { + index: 0, + id: result.toolCall.id, + type: "function", + function: { + name: result.toolCall.function.name, + arguments: result.toolCall.function.arguments, + }, + }, + ], + }, + null, + ); + yield makeChunk(id, created, {}, "tool_calls"); + } else { + yield makeChunk(id, created, { content: result.content as string }, null); + yield makeChunk(id, created, {}, "stop"); + } + return; + } + + // Plain text streaming + for await (const delta of session.streamResponse(prompt, { options })) { + yield makeChunk(id, created, { content: delta }, null); + } + + // Final chunk + yield makeChunk(id, created, {}, "stop"); + } catch (err) { + // Map errors to finish_reason chunks + if (err instanceof ExceededContextWindowSizeError) { + yield makeChunk(id, created, {}, "length"); + return; + } + if (err instanceof RefusalError) { + yield makeChunk(id, created, { refusal: err.message }, null); + yield makeChunk(id, created, {}, "stop"); + return; + } + if (err instanceof RateLimitedError) { + throw new CompatError(err.message, 429); + } + if (err instanceof GuardrailViolationError) { + yield makeChunk(id, created, {}, "content_filter"); + return; + } + throw err; + } + } + + return new Stream(generate(), () => session.dispose()); + } +} + +class Chat { + completions: Completions; + + constructor(getModel: () => SystemLanguageModel) { + this.completions = new Completions(getModel); + } +} + +// --------------------------------------------------------------------------- +// Main Client class +// --------------------------------------------------------------------------- + +/** + * Chat-style and Responses-style API client backed by Apple Foundation Models + * on-device inference. + * + * Supports both `chat.completions.create()` (Chat-style API) and + * `responses.create()` (Responses-style API) with text, streaming, structured + * output, and tool calling. Each call is stateless: the input is replayed + * into a native transcript, generation runs, and the session is auto-disposed. + * + * Call `close()` when done to release the underlying model. + */ +export default class Client { + chat: Chat; + responses: Responses; + private _model: SystemLanguageModel; + + constructor() { + this._model = new SystemLanguageModel(); + this.chat = new Chat(() => this._model); + this.responses = new Responses(() => this._model); + } + + close(): void { + this._model.dispose(); + } + + [Symbol.dispose](): void { + this.close(); + } +} + +// --------------------------------------------------------------------------- +// Response builders +// --------------------------------------------------------------------------- + +function buildCompletion( + content: string | null, + finishReason: "stop" | "length" | "tool_calls" | "content_filter", + toolCalls?: ChatCompletion["choices"][0]["message"]["tool_calls"], +): ChatCompletion { + return { + id: makeId(), + object: "chat.completion", + created: nowSeconds(), + model: MODEL_DEFAULT, + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + refusal: null, + ...(toolCalls ? { tool_calls: toolCalls } : {}), + }, + finish_reason: finishReason, + }, + ], + usage: null, + system_fingerprint: null, + }; +} + +function makeChunk( + id: string, + created: number, + delta: ChatCompletionChunk["choices"][0]["delta"], + finishReason: ChatCompletionChunk["choices"][0]["finish_reason"], +): ChatCompletionChunk { + return { + id, + object: "chat.completion.chunk", + created, + model: MODEL_DEFAULT, + choices: [ + { + index: 0, + delta, + finish_reason: finishReason, + }, + ], + usage: null, + system_fingerprint: null, + }; +} diff --git a/src/compat/params.ts b/src/compat/params.ts new file mode 100644 index 0000000..d34469e --- /dev/null +++ b/src/compat/params.ts @@ -0,0 +1,90 @@ +import { SamplingMode, type GenerationOptions } from "../options.js"; +import type { ChatCompletionCreateParams } from "./types.js"; + +/** Params accepted for type compat but not supported by Apple Foundation Models. Warned at runtime. */ +const UNSUPPORTED_PARAMS: ReadonlyArray = [ + "n", + "stop", + "logprobs", + "top_logprobs", + "frequency_penalty", + "presence_penalty", + "logit_bias", + "parallel_tool_calls", + "service_tier", + "store", + "metadata", + "prediction", + "reasoning_effort", + "audio", + "modalities", + "user", + "stream_options", + "verbosity", + "web_search_options", + "prompt_cache_key", + "prompt_cache_retention", + "safety_identifier", + "function_call", + "functions", +]; + +/** + * Maps ChatCompletionCreateParams into tsfm's GenerationOptions. + * Emits console.warn for unsupported params and non-standard model names. + */ +export function mapParams(params: Partial): GenerationOptions { + const options: GenerationOptions = {}; + + // Warn on non-standard model names + if (params.model !== undefined && params.model !== "SystemLanguageModel") { + console.warn( + `[tsfm compat] Model "${params.model}" is not supported. Use "SystemLanguageModel" or omit the model field.`, + ); + } + + // temperature — independent of sampling mode + if (params.temperature != null) { + options.temperature = params.temperature; + } + + // max_completion_tokens takes priority over max_tokens + if (params.max_completion_tokens != null) { + if (params.max_tokens != null) { + console.warn( + `[tsfm compat] Both "max_tokens" and "max_completion_tokens" are set. "max_completion_tokens" will be used.`, + ); + } + options.maximumResponseTokens = params.max_completion_tokens; + } else if (params.max_tokens != null) { + options.maximumResponseTokens = params.max_tokens; + } + + // Build sampling mode from top_p and/or seed + const topP = params.top_p ?? undefined; + const seed = params.seed ?? undefined; + + if (topP !== undefined || seed !== undefined) { + options.sampling = SamplingMode.random({ + ...(topP !== undefined ? { probabilityThreshold: topP } : {}), + ...(seed !== undefined ? { seed } : {}), + }); + } + + // Specific warning for tool_choice since it affects expected behavior + if (params.tool_choice != null && params.tool_choice !== "auto") { + console.warn( + `[tsfm compat] Parameter "tool_choice" value "${typeof params.tool_choice === "string" ? params.tool_choice : "object"}" is not supported. ` + + `Apple Foundation Models always uses "auto" tool selection. The parameter will be ignored.`, + ); + } + + // Warn on unsupported params that are non-null + for (const key of UNSUPPORTED_PARAMS) { + if (params[key] != null) { + console.warn(`[tsfm compat] Parameter "${key}" is not supported and will be ignored.`); + } + } + + return options; +} diff --git a/src/compat/responses-stream.ts b/src/compat/responses-stream.ts new file mode 100644 index 0000000..fda9fe7 --- /dev/null +++ b/src/compat/responses-stream.ts @@ -0,0 +1,72 @@ +import type { ResponseStreamEvent } from "./responses-types.js"; + +const _streamRegistry = new FinalizationRegistry((cleanup: () => void) => { + cleanup(); +}); + +/** + * Async iterable wrapper for Responses API streaming events, mirroring + * the standard Responses API stream. + * + * Can only be iterated once. Call `close()` to release resources early. + */ +export class ResponseStream implements AsyncIterable { + private _iterator: AsyncIterator; + private _cleanup?: () => void; + + constructor(source: AsyncIterable, cleanup?: () => void) { + const inner = source[Symbol.asyncIterator](); + this._cleanup = cleanup; + if (cleanup) { + _streamRegistry.register(this, cleanup, this); + } + + this._iterator = { + next: async () => { + try { + const result = await inner.next(); + if (result.done) this.close(); + return result; + } catch (err) { + this.close(); + throw err; + } + }, + return: async (value?: ResponseStreamEvent) => { + this.close(); + return inner.return?.(value) ?? { done: true as const, value }; + }, + }; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this._iterator; + } + + /** Release resources without consuming remaining events. */ + close(): void { + if (this._cleanup) { + _streamRegistry.unregister(this); + this._cleanup(); + this._cleanup = undefined; + } + } + + toReadableStream(): ReadableStream { + const iterator = this._iterator; + return new ReadableStream({ + async pull(controller) { + try { + const { value, done } = await iterator.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + } catch (err) { + controller.error(err); + } + }, + }); + } +} diff --git a/src/compat/responses-types.ts b/src/compat/responses-types.ts new file mode 100644 index 0000000..855075c --- /dev/null +++ b/src/compat/responses-types.ts @@ -0,0 +1,349 @@ +// Responses API type definitions for the Apple Foundation Models compat layer. +// This is a pure types file with no runtime code. + +import type { JsonSchema } from "../schema.js"; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export type ResponseInputText = { + type: "input_text"; + text: string; +}; + +export type ResponseInputImage = { + type: "input_image"; + image_url?: string; + file_id?: string; + detail?: "low" | "high" | "auto"; +}; + +export type ResponseInputFile = { + type: "input_file"; + file_data?: string; + file_id?: string; + filename?: string; +}; + +export type ResponseInputContent = ResponseInputText | ResponseInputImage | ResponseInputFile; + +export type EasyInputMessage = { + role: "user" | "assistant" | "system" | "developer"; + content: string | ResponseInputContent[]; + type?: "message"; + name?: string; +}; + +export type ResponseFunctionToolCall = { + type: "function_call"; + name: string; + arguments: string; + call_id: string; + id?: string; + status?: "in_progress" | "completed" | "incomplete"; +}; + +export type FunctionCallOutput = { + type: "function_call_output"; + call_id: string; + output: string; + id?: string | null; +}; + +export type ResponseInputItem = EasyInputMessage | ResponseFunctionToolCall | FunctionCallOutput; + +// --------------------------------------------------------------------------- +// Tool types +// --------------------------------------------------------------------------- + +export type FunctionTool = { + type: "function"; + name: string; + parameters: JsonSchema | null; + description?: string; + strict?: boolean | null; +}; + +export type ResponseTool = FunctionTool; + +// --------------------------------------------------------------------------- +// Structured output (text.format) +// --------------------------------------------------------------------------- + +export type ResponseFormatText = { type: "text" }; + +export type ResponseFormatJsonObject = { type: "json_object" }; + +export type ResponseFormatJsonSchema = { + type: "json_schema"; + name: string; + schema: JsonSchema; + description?: string; + strict?: boolean | null; +}; + +export type ResponseFormatConfig = + | ResponseFormatText + | ResponseFormatJsonObject + | ResponseFormatJsonSchema; + +export type ResponseTextConfig = { + format?: ResponseFormatConfig; +}; + +// --------------------------------------------------------------------------- +// Request params +// --------------------------------------------------------------------------- + +/** + * Request params for `responses.create()`. + * + * All Responses API params are accepted for type compatibility. Supported + * params are mapped to Foundation Models GenerationOptions; unsupported params + * are warned and silently ignored at runtime. + */ +export type ResponseCreateParams = { + // Core + input: string | ResponseInputItem[]; + model?: string; + instructions?: string | null; + + // Sampling + temperature?: number | null; + top_p?: number | null; + max_output_tokens?: number | null; + + // Tools + tools?: ResponseTool[]; + tool_choice?: "none" | "auto" | "required" | { type: "function"; name: string }; + + // Structured output + text?: ResponseTextConfig; + + // Streaming + stream?: boolean | null; + + // Accepted but ignored + previous_response_id?: string | null; + conversation?: string | { id: string } | null; + store?: boolean | null; + truncation?: "auto" | "disabled" | null; + metadata?: Record | null; + include?: string[] | null; + reasoning?: { effort?: string; summary?: string | null } | null; + parallel_tool_calls?: boolean | null; + service_tier?: string | null; + user?: string; + seed?: number | null; + stream_options?: { include_obfuscation?: boolean } | null; + background?: boolean | null; + safety_identifier?: string; + prompt_cache_key?: string; + prompt_cache_retention?: string | null; +}; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +export type ResponseOutputText = { + type: "output_text"; + text: string; + annotations: unknown[]; +}; + +export type ResponseOutputRefusal = { + type: "refusal"; + refusal: string; +}; + +export type ResponseOutputContent = ResponseOutputText | ResponseOutputRefusal; + +export type ResponseOutputMessage = { + id: string; + type: "message"; + role: "assistant"; + status: "completed" | "incomplete" | "in_progress"; + content: ResponseOutputContent[]; +}; + +export type ResponseOutputFunctionToolCall = { + type: "function_call"; + id: string; + call_id: string; + name: string; + arguments: string; + status: "completed"; +}; + +export type ResponseOutputItem = ResponseOutputMessage | ResponseOutputFunctionToolCall; + +// --------------------------------------------------------------------------- +// Response object +// --------------------------------------------------------------------------- + +export type ResponseError = { + code: string; + message: string; +}; + +export type Response = { + id: string; + object: "response"; + created_at: number; + model: string; + output: ResponseOutputItem[]; + output_text: string; + status: "completed" | "failed" | "incomplete"; + error: ResponseError | null; + incomplete_details: { reason?: "max_output_tokens" | "content_filter" } | null; + instructions: string | null; + metadata: Record | null; + temperature: number | null; + top_p: number | null; + max_output_tokens: number | null; + tool_choice: "none" | "auto" | "required" | { type: "function"; name: string }; + tools: ResponseTool[]; + parallel_tool_calls: boolean; + text: ResponseTextConfig; + truncation: "auto" | "disabled" | null; + usage: null; +}; + +// --------------------------------------------------------------------------- +// Streaming event types +// --------------------------------------------------------------------------- + +export type ResponseCreatedEvent = { + type: "response.created"; + response: Response; + sequence_number: number; +}; + +export type ResponseInProgressEvent = { + type: "response.in_progress"; + response: Response; + sequence_number: number; +}; + +export type ResponseCompletedEvent = { + type: "response.completed"; + response: Response; + sequence_number: number; +}; + +export type ResponseFailedEvent = { + type: "response.failed"; + response: Response; + sequence_number: number; +}; + +export type ResponseIncompleteEvent = { + type: "response.incomplete"; + response: Response; + sequence_number: number; +}; + +export type ResponseOutputItemAddedEvent = { + type: "response.output_item.added"; + item: ResponseOutputItem; + output_index: number; + sequence_number: number; +}; + +export type ResponseOutputItemDoneEvent = { + type: "response.output_item.done"; + item: ResponseOutputItem; + output_index: number; + sequence_number: number; +}; + +export type ResponseContentPartAddedEvent = { + type: "response.content_part.added"; + part: ResponseOutputContent; + item_id: string; + output_index: number; + content_index: number; + sequence_number: number; +}; + +export type ResponseContentPartDoneEvent = { + type: "response.content_part.done"; + part: ResponseOutputContent; + item_id: string; + output_index: number; + content_index: number; + sequence_number: number; +}; + +export type ResponseTextDeltaEvent = { + type: "response.output_text.delta"; + delta: string; + item_id: string; + output_index: number; + content_index: number; + sequence_number: number; +}; + +export type ResponseTextDoneEvent = { + type: "response.output_text.done"; + text: string; + item_id: string; + output_index: number; + content_index: number; + sequence_number: number; +}; + +export type ResponseRefusalDeltaEvent = { + type: "response.refusal.delta"; + delta: string; + item_id: string; + output_index: number; + content_index: number; + sequence_number: number; +}; + +export type ResponseRefusalDoneEvent = { + type: "response.refusal.done"; + refusal: string; + item_id: string; + output_index: number; + content_index: number; + sequence_number: number; +}; + +export type ResponseFunctionCallArgumentsDeltaEvent = { + type: "response.function_call_arguments.delta"; + delta: string; + item_id: string; + output_index: number; + sequence_number: number; +}; + +export type ResponseFunctionCallArgumentsDoneEvent = { + type: "response.function_call_arguments.done"; + arguments: string; + name: string; + call_id: string; + item_id: string; + output_index: number; + sequence_number: number; +}; + +export type ResponseStreamEvent = + | ResponseCreatedEvent + | ResponseInProgressEvent + | ResponseCompletedEvent + | ResponseFailedEvent + | ResponseIncompleteEvent + | ResponseOutputItemAddedEvent + | ResponseOutputItemDoneEvent + | ResponseContentPartAddedEvent + | ResponseContentPartDoneEvent + | ResponseTextDeltaEvent + | ResponseTextDoneEvent + | ResponseRefusalDeltaEvent + | ResponseRefusalDoneEvent + | ResponseFunctionCallArgumentsDeltaEvent + | ResponseFunctionCallArgumentsDoneEvent; diff --git a/src/compat/responses.ts b/src/compat/responses.ts new file mode 100644 index 0000000..003765a --- /dev/null +++ b/src/compat/responses.ts @@ -0,0 +1,790 @@ +import { randomUUID } from "node:crypto"; +import { SystemLanguageModel } from "../core.js"; +import { LanguageModelSession } from "../session.js"; +import { Transcript } from "../transcript.js"; +import type { JsonObject } from "../schema.js"; +import { SamplingMode, type GenerationOptions } from "../options.js"; +import { + ExceededContextWindowSizeError, + RefusalError, + RateLimitedError, + GuardrailViolationError, +} from "../errors.js"; +import { + buildToolInstructions, + buildToolSchema, + parseToolResponse, + type ToolModelOutput, +} from "./tools.js"; +import { ResponseStream } from "./responses-stream.js"; +import { reorderJson, nowSeconds, CompatError } from "./utils.js"; +import type { + ResponseCreateParams, + Response, + ResponseOutputItem, + ResponseOutputMessage, + ResponseOutputFunctionToolCall, + ResponseStreamEvent, + ResponseInputItem, + EasyInputMessage, + FunctionCallOutput, + ResponseFunctionToolCall, + FunctionTool, + ResponseFormatJsonSchema, +} from "./responses-types.js"; +import type { ChatCompletionTool } from "./types.js"; + +const MODEL_DEFAULT = "SystemLanguageModel"; + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +interface TranscriptContentItem { + type: "text"; + text: string; + id: string; +} + +interface TranscriptEntry { + role: "instructions" | "user" | "response"; + id: string; + options?: JsonObject; + contents: TranscriptContentItem[]; +} + +interface NativeTranscript { + type: "FoundationModels.Transcript"; + version: 1; + transcript: { + entries: TranscriptEntry[]; + }; +} + +// --------------------------------------------------------------------------- +// Unsupported params +// --------------------------------------------------------------------------- + +const UNSUPPORTED_PARAMS: ReadonlyArray = [ + "previous_response_id", + "conversation", + "store", + "truncation", + "metadata", + "include", + "reasoning", + "parallel_tool_calls", + "service_tier", + "user", + "stream_options", + "background", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeId(): string { + return "resp_" + randomUUID(); +} + +function makeContentItem(text: string): TranscriptContentItem { + return { type: "text", text, id: randomUUID() }; +} + +function makeEntry( + role: TranscriptEntry["role"], + text: string, + withOptions = false, +): TranscriptEntry { + const entry: TranscriptEntry = { + role, + id: randomUUID(), + contents: [makeContentItem(text)], + }; + if (withOptions) entry.options = {}; + return entry; +} + +/** Extract plain text from input content. */ +function extractInputText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + const unsupported = new Set(); + for (const part of content) { + if (part.type !== "input_text") unsupported.add(part.type); + } + for (const type of unsupported) { + console.warn( + `[tsfm compat] ${type} content parts are not supported by Apple Foundation Models and will be ignored.`, + ); + } + return content + .filter((p) => p.type === "input_text" && p.text != null) + .map((p) => p.text as string) + .join(""); +} + +/** Map ResponseCreateParams to native GenerationOptions. */ +function mapResponseParams(params: ResponseCreateParams): GenerationOptions { + const options: GenerationOptions = {}; + + if (params.model !== undefined && params.model !== "SystemLanguageModel") { + console.warn( + `[tsfm compat] Model "${params.model}" is not supported. Use "SystemLanguageModel" or omit the model field.`, + ); + } + + if (params.temperature != null) options.temperature = params.temperature; + if (params.max_output_tokens != null) options.maximumResponseTokens = params.max_output_tokens; + + const topP = params.top_p ?? undefined; + const seed = params.seed ?? undefined; + if (topP !== undefined || seed !== undefined) { + options.sampling = SamplingMode.random({ + ...(topP !== undefined ? { probabilityThreshold: topP } : {}), + ...(seed !== undefined ? { seed } : {}), + }); + } + + if (params.tool_choice != null && params.tool_choice !== "auto") { + console.warn( + `[tsfm compat] Parameter "tool_choice" value "${typeof params.tool_choice === "string" ? params.tool_choice : "object"}" is not supported. ` + + `Apple Foundation Models always uses "auto" tool selection. The parameter will be ignored.`, + ); + } + + for (const key of UNSUPPORTED_PARAMS) { + if (params[key] != null) { + console.warn(`[tsfm compat] Parameter "${key}" is not supported and will be ignored.`); + } + } + + return options; +} + +/** Convert Responses API FunctionTool[] to ChatCompletionTool[] for reuse of tool schema logic. */ +function toCompletionTools(tools: FunctionTool[]): ChatCompletionTool[] { + return tools.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: t.parameters ?? undefined, + strict: t.strict, + }, + })); +} + +/** + * Convert Responses API input (string or item array) into a native transcript + * JSON string and a prompt string. + */ +function inputToTranscript( + input: string | ResponseInputItem[], + instructions?: string | null, +): { transcriptJson: string; prompt: string } { + // Simple string input + if (typeof input === "string") { + const entries: TranscriptEntry[] = []; + if (instructions) entries.push(makeEntry("instructions", instructions)); + return { + transcriptJson: JSON.stringify({ + type: "FoundationModels.Transcript", + version: 1, + transcript: { entries }, + } satisfies NativeTranscript), + prompt: input, + }; + } + + // Array input — find the last user message as prompt + // Process function_call / function_call_output items specially + const entries: TranscriptEntry[] = []; + let seenInstructions = false; + + if (instructions) { + entries.push(makeEntry("instructions", instructions)); + seenInstructions = true; + } + + // Normalize: if last item is function_call_output, append a synthetic user message + let normalized = input; + const lastItem = input[input.length - 1]; + if (lastItem && (lastItem as FunctionCallOutput).type === "function_call_output") { + // Collect contiguous function_call_output items from the end + let start = input.length - 1; + while (start > 0 && (input[start - 1] as FunctionCallOutput).type === "function_call_output") { + start--; + } + const outputs = input.slice(start) as FunctionCallOutput[]; + const parts: string[] = []; + for (const out of outputs) { + const name = resolveCallName(out.call_id, input); + parts.push( + name != null ? `[Tool result for ${name}]: ${out.output}` : `[Tool result]: ${out.output}`, + ); + } + normalized = [ + ...input, + { role: "user" as const, content: parts.join("\n") } as EasyInputMessage, + ]; + } + + // Find the last user message to use as prompt + let promptIndex = -1; + for (let i = normalized.length - 1; i >= 0; i--) { + const item = normalized[i] as EasyInputMessage; + if (item.role === "user") { + promptIndex = i; + break; + } + } + + if (promptIndex === -1) { + throw new Error("Input must contain at least one user message"); + } + + const promptItem = normalized[promptIndex] as EasyInputMessage; + const prompt = extractInputText(promptItem.content); + + // Build transcript from all items except the last user message + for (let i = 0; i < normalized.length; i++) { + if (i === promptIndex) continue; + const item = normalized[i]; + + if ((item as EasyInputMessage).role !== undefined) { + const msg = item as EasyInputMessage; + if (msg.role === "system" || msg.role === "developer") { + const text = extractInputText(msg.content); + if (!seenInstructions) { + entries.push(makeEntry("instructions", text)); + seenInstructions = true; + } else { + entries.push(makeEntry("user", `[System] ${text}`, true)); + } + } else if (msg.role === "user") { + entries.push(makeEntry("user", extractInputText(msg.content), true)); + } else if (msg.role === "assistant") { + entries.push(makeEntry("response", extractInputText(msg.content))); + } + } else if ((item as ResponseFunctionToolCall).type === "function_call") { + const fc = item as ResponseFunctionToolCall; + entries.push( + makeEntry( + "response", + JSON.stringify([ + { + id: fc.call_id, + type: "function", + function: { name: fc.name, arguments: fc.arguments }, + }, + ]), + ), + ); + } else if ((item as FunctionCallOutput).type === "function_call_output") { + const fco = item as FunctionCallOutput; + const name = resolveCallName(fco.call_id, input); + const text = + name != null ? `[Tool result for ${name}]: ${fco.output}` : `[Tool result]: ${fco.output}`; + entries.push(makeEntry("user", text, true)); + } + } + + return { + transcriptJson: JSON.stringify({ + type: "FoundationModels.Transcript", + version: 1, + transcript: { entries }, + } satisfies NativeTranscript), + prompt, + }; +} + +/** Find a function_call's name by its call_id. */ +function resolveCallName(callId: string, items: ResponseInputItem[]): string | null { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i] as ResponseFunctionToolCall; + if (item.type === "function_call" && item.call_id === callId) { + return item.name; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Response builders +// --------------------------------------------------------------------------- + +function buildResponse( + params: ResponseCreateParams, + output: ResponseOutputItem[], + status: "completed" | "failed" | "incomplete", + error: { code: string; message: string } | null = null, + incompleteReason?: "max_output_tokens" | "content_filter", +): Response { + const outputText = output + .filter((item): item is ResponseOutputMessage => item.type === "message") + .flatMap((msg) => msg.content) + .filter((c) => c.type === "output_text") + .map((c) => (c as { type: "output_text"; text: string }).text) + .join(""); + + return { + id: makeId(), + object: "response", + created_at: nowSeconds(), + model: MODEL_DEFAULT, + output, + output_text: outputText, + status, + error, + incomplete_details: incompleteReason ? { reason: incompleteReason } : null, + instructions: params.instructions ?? null, + metadata: params.metadata ?? null, + temperature: params.temperature ?? null, + top_p: params.top_p ?? null, + max_output_tokens: params.max_output_tokens ?? null, + tool_choice: params.tool_choice ?? "auto", + tools: params.tools ?? [], + parallel_tool_calls: params.parallel_tool_calls ?? false, + text: params.text ?? { format: { type: "text" } }, + truncation: params.truncation ?? null, + usage: null, + }; +} + +function makeOutputMessage( + content: string, + status: "completed" | "incomplete" = "completed", +): ResponseOutputMessage { + return { + id: "msg_" + randomUUID(), + type: "message", + role: "assistant", + status, + content: [ + { + type: "output_text", + text: content, + annotations: [], + }, + ], + }; +} + +function makeRefusalMessage(refusal: string): ResponseOutputMessage { + return { + id: "msg_" + randomUUID(), + type: "message", + role: "assistant", + status: "completed", + content: [ + { + type: "refusal", + refusal, + }, + ], + }; +} + +function makeFunctionCall(name: string, args: string): ResponseOutputFunctionToolCall { + return { + type: "function_call", + id: "fc_" + randomUUID(), + call_id: "call_" + randomUUID(), + name, + arguments: args, + status: "completed", + }; +} + +// --------------------------------------------------------------------------- +// Responses class +// --------------------------------------------------------------------------- + +export class Responses { + private _getModel: () => SystemLanguageModel; + + constructor(getModel: () => SystemLanguageModel) { + this._getModel = getModel; + } + + async create(params: ResponseCreateParams & { stream: true }): Promise; + async create(params: ResponseCreateParams & { stream?: false | null }): Promise; + async create(params: ResponseCreateParams): Promise; + async create(params: ResponseCreateParams): Promise { + const options = mapResponseParams(params); + const { transcriptJson, prompt: rawPrompt } = inputToTranscript( + params.input, + params.instructions, + ); + let prompt = rawPrompt; + let transcriptStr = transcriptJson; + + // Determine if we should apply tools — skip if last input is a function result + const functionTools = + params.tools?.filter((t): t is FunctionTool => t.type === "function") ?? []; + const lastInput = Array.isArray(params.input) ? params.input[params.input.length - 1] : null; + const isToolResult = + lastInput != null && (lastInput as FunctionCallOutput).type === "function_call_output"; + const activeTools = isToolResult ? [] : functionTools; + + // Convert to ChatCompletionTool format for reuse of existing tool logic + const completionTools = activeTools.length > 0 ? toCompletionTools(activeTools) : undefined; + + // Inject tool instructions + if (completionTools && completionTools.length > 0) { + const toolInstructions = buildToolInstructions(completionTools); + const parsed = JSON.parse(transcriptStr) as NativeTranscript; + const instrEntry = parsed.transcript.entries.find((e) => e.role === "instructions"); + if (instrEntry) { + instrEntry.contents[0].text += toolInstructions; + } else { + parsed.transcript.entries.unshift({ + role: "instructions", + id: randomUUID(), + contents: [makeContentItem(toolInstructions.trimStart())], + }); + } + transcriptStr = JSON.stringify(parsed); + prompt += "\n\nRemember: if a tool can help answer this, use type tool_call."; + } + + const transcript = Transcript.fromJson(transcriptStr); + const model = this._getModel(); + const session = LanguageModelSession.fromTranscript(transcript, { model }); + + if (params.stream) { + return this._createStream(session, prompt, options, params, completionTools); + } + + return this._createResponse(session, prompt, options, params, completionTools); + } + + private async _createResponse( + session: LanguageModelSession, + prompt: string, + options: GenerationOptions, + params: ResponseCreateParams, + tools?: ChatCompletionTool[], + ): Promise { + try { + // Tools → structured output with tool schema + if (tools && tools.length > 0) { + const schema = buildToolSchema(tools); + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + const parsed = JSON.parse(content.toJson()) as ToolModelOutput; + const result = parseToolResponse(parsed); + + if (result.type === "tool_call" && result.toolCall) { + const fc = makeFunctionCall( + result.toolCall.function.name, + result.toolCall.function.arguments, + ); + return buildResponse(params, [fc], "completed"); + } + return buildResponse(params, [makeOutputMessage(result.content as string)], "completed"); + } + + // Structured output via text.format + const format = params.text?.format; + if (format?.type === "json_schema") { + const jsFormat = format as ResponseFormatJsonSchema; + const schema = jsFormat.schema ?? { type: "object" }; + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + return buildResponse( + params, + [makeOutputMessage(reorderJson(content.toJson(), schema))], + "completed", + ); + } + + // Plain text + const text = await session.respond(prompt, { options }); + return buildResponse(params, [makeOutputMessage(text)], "completed"); + } catch (err) { + if (err instanceof ExceededContextWindowSizeError) { + return buildResponse( + params, + [makeOutputMessage("", "incomplete")], + "incomplete", + { code: "max_output_tokens", message: err.message }, + "max_output_tokens", + ); + } + if (err instanceof RefusalError) { + return buildResponse(params, [makeRefusalMessage(err.message)], "completed"); + } + if (err instanceof RateLimitedError) { + throw new CompatError(err.message, 429); + } + if (err instanceof GuardrailViolationError) { + return buildResponse( + params, + [], + "failed", + { code: "content_filter", message: err.message }, + "content_filter", + ); + } + throw err; + } finally { + session.dispose(); + } + } + + private _createStream( + session: LanguageModelSession, + prompt: string, + options: GenerationOptions, + params: ResponseCreateParams, + tools?: ChatCompletionTool[], + ): ResponseStream { + let seq = 0; + + async function* generate(): AsyncGenerator { + try { + // response.created + const initialResponse = buildResponse(params, [], "completed"); + yield { type: "response.created", response: initialResponse, sequence_number: seq++ }; + yield { type: "response.in_progress", response: initialResponse, sequence_number: seq++ }; + + // Tools → buffer full response + if (tools && tools.length > 0) { + const schema = buildToolSchema(tools); + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + const parsed = JSON.parse(content.toJson()) as ToolModelOutput; + const result = parseToolResponse(parsed); + + if (result.type === "tool_call" && result.toolCall) { + const fc = makeFunctionCall( + result.toolCall.function.name, + result.toolCall.function.arguments, + ); + + yield { + type: "response.output_item.added", + item: fc, + output_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.function_call_arguments.delta", + delta: fc.arguments, + item_id: fc.id, + output_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.function_call_arguments.done", + arguments: fc.arguments, + name: fc.name, + call_id: fc.call_id, + item_id: fc.id, + output_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.output_item.done", + item: fc, + output_index: 0, + sequence_number: seq++, + }; + + const finalResponse = buildResponse(params, [fc], "completed"); + yield { type: "response.completed", response: finalResponse, sequence_number: seq++ }; + return; + } + + // Text response from tool schema + const msg = makeOutputMessage(result.content as string); + yield* emitTextMessage(msg, 0); + const finalResponse = buildResponse(params, [msg], "completed"); + yield { type: "response.completed", response: finalResponse, sequence_number: seq++ }; + return; + } + + // Structured output → buffer + const format = params.text?.format; + if (format?.type === "json_schema") { + const jsFormat = format as ResponseFormatJsonSchema; + const schema = jsFormat.schema ?? { type: "object" }; + const content = await session.respondWithJsonSchema(prompt, schema, { options }); + const text = reorderJson(content.toJson(), schema); + const msg = makeOutputMessage(text); + yield* emitTextMessage(msg, 0); + const finalResponse = buildResponse(params, [msg], "completed"); + yield { type: "response.completed", response: finalResponse, sequence_number: seq++ }; + return; + } + + // Plain text streaming + const msgId = "msg_" + randomUUID(); + const outputItem: ResponseOutputMessage = { + id: msgId, + type: "message", + role: "assistant", + status: "in_progress", + content: [{ type: "output_text", text: "", annotations: [] }], + }; + + yield { + type: "response.output_item.added", + item: outputItem, + output_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.content_part.added", + part: { type: "output_text", text: "", annotations: [] }, + item_id: msgId, + output_index: 0, + content_index: 0, + sequence_number: seq++, + }; + + let fullText = ""; + for await (const delta of session.streamResponse(prompt, { options })) { + fullText += delta; + yield { + type: "response.output_text.delta", + delta, + item_id: msgId, + output_index: 0, + content_index: 0, + sequence_number: seq++, + }; + } + + yield { + type: "response.output_text.done", + text: fullText, + item_id: msgId, + output_index: 0, + content_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.content_part.done", + part: { type: "output_text", text: fullText, annotations: [] }, + item_id: msgId, + output_index: 0, + content_index: 0, + sequence_number: seq++, + }; + + const doneItem: ResponseOutputMessage = { + id: msgId, + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: fullText, annotations: [] }], + }; + yield { + type: "response.output_item.done", + item: doneItem, + output_index: 0, + sequence_number: seq++, + }; + + const finalResponse = buildResponse(params, [doneItem], "completed"); + yield { type: "response.completed", response: finalResponse, sequence_number: seq++ }; + } catch (err) { + if (err instanceof ExceededContextWindowSizeError) { + const resp = buildResponse( + params, + [], + "incomplete", + { code: "max_output_tokens", message: err.message }, + "max_output_tokens", + ); + yield { type: "response.incomplete", response: resp, sequence_number: seq++ }; + return; + } + if (err instanceof RefusalError) { + const msg = makeRefusalMessage(err.message); + const resp = buildResponse(params, [msg], "completed"); + yield { type: "response.completed", response: resp, sequence_number: seq++ }; + return; + } + if (err instanceof RateLimitedError) { + throw new CompatError(err.message, 429); + } + if (err instanceof GuardrailViolationError) { + const resp = buildResponse( + params, + [], + "failed", + { code: "content_filter", message: err.message }, + "content_filter", + ); + yield { type: "response.failed", response: resp, sequence_number: seq++ }; + return; + } + throw err; + } + } + + function* emitTextMessage( + msg: ResponseOutputMessage, + outputIndex: number, + ): Generator { + const textContent = msg.content[0] as { + type: "output_text"; + text: string; + annotations: unknown[]; + }; + const text = textContent.text; + + yield { + type: "response.output_item.added" as const, + item: msg, + output_index: outputIndex, + sequence_number: seq++, + }; + yield { + type: "response.content_part.added" as const, + part: textContent, + item_id: msg.id, + output_index: outputIndex, + content_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.output_text.delta" as const, + delta: text, + item_id: msg.id, + output_index: outputIndex, + content_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.output_text.done" as const, + text, + item_id: msg.id, + output_index: outputIndex, + content_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.content_part.done" as const, + part: textContent, + item_id: msg.id, + output_index: outputIndex, + content_index: 0, + sequence_number: seq++, + }; + yield { + type: "response.output_item.done" as const, + item: msg, + output_index: outputIndex, + sequence_number: seq++, + }; + } + + return new ResponseStream(generate(), () => session.dispose()); + } +} diff --git a/src/compat/stream.ts b/src/compat/stream.ts new file mode 100644 index 0000000..acd2830 --- /dev/null +++ b/src/compat/stream.ts @@ -0,0 +1,78 @@ +import type { ChatCompletionChunk } from "./types.js"; + +const _streamRegistry = new FinalizationRegistry((cleanup: () => void) => { + cleanup(); +}); + +/** + * Async iterable wrapper for streaming chat completion chunks, mirroring the standard Chat Completions `Stream`. + * + * Like a standard `Stream`, this can only be iterated once. A second + * `for await` loop over the same instance will yield no chunks. + * + * Call `close()` when done if you do not fully consume the stream, to release + * the underlying native session. Fully consumed streams clean up automatically. + */ +export class Stream implements AsyncIterable { + private _iterator: AsyncIterator; + private _cleanup?: () => void; + + constructor(source: AsyncIterable, cleanup?: () => void) { + const inner = source[Symbol.asyncIterator](); + this._cleanup = cleanup; + if (cleanup) { + _streamRegistry.register(this, cleanup, this); + } + + // Wrap the inner iterator so that exhaustion (done) or error + // automatically triggers close(), eagerly releasing resources + // instead of waiting for GC via FinalizationRegistry. + this._iterator = { + next: async () => { + try { + const result = await inner.next(); + if (result.done) this.close(); + return result; + } catch (err) { + this.close(); + throw err; + } + }, + return: async (value?: ChatCompletionChunk) => { + this.close(); + return inner.return?.(value) ?? { done: true as const, value }; + }, + }; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this._iterator; + } + + /** Release resources associated with this stream without consuming remaining chunks. */ + close(): void { + if (this._cleanup) { + _streamRegistry.unregister(this); + this._cleanup(); + this._cleanup = undefined; + } + } + + toReadableStream(): ReadableStream { + const iterator = this._iterator; + return new ReadableStream({ + async pull(controller) { + try { + const { value, done } = await iterator.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + } catch (err) { + controller.error(err); + } + }, + }); + } +} diff --git a/src/compat/tools.ts b/src/compat/tools.ts new file mode 100644 index 0000000..fd237d7 --- /dev/null +++ b/src/compat/tools.ts @@ -0,0 +1,188 @@ +import { randomUUID } from "node:crypto"; +import type { JsonSchema } from "../schema.js"; +import type { ChatCompletionTool, ChatCompletionMessageToolCall } from "./types.js"; + +export interface ToolParseResult { + type: "text" | "tool_call"; + content?: string; + toolCall?: ChatCompletionMessageToolCall; +} + +export interface ToolModelOutput { + type: string; + tool_call?: { name: string; arguments?: JsonSchema }; + content?: string; +} + +/** + * Generates prompt text describing available tools, appended to existing instructions. + * The leading newline is intentional. + */ +export function buildToolInstructions(tools: ChatCompletionTool[]): string { + const toolList: string[] = []; + for (const tool of tools) { + const { name, description, parameters } = tool.function; + toolList.push(` - name: "${name}"`); + if (description) toolList.push(` description: ${description}`); + toolList.push(` parameters: ${JSON.stringify(parameters ?? {})}`); + } + + return [ + "", + "# Tool Use Instructions", + "", + "You have access to external tools. You MUST decide whether to call a tool or respond with text.", + "", + "## Available Tools", + ...toolList, + "", + "## Response Rules", + "1. If the user's request matches ANY tool's purpose, you MUST respond with type \"tool_call\".", + '2. Only respond with type "text" for general conversation that no tool can help with.', + "3. Never describe a tool call in text — always use the tool_call structure.", + "4. Fill in the tool arguments based on the user's request.", + ].join("\n"); +} + +/** + * Builds a JSON schema for structured output that discriminates between + * a text response and a tool call. + * + * All tool parameters are merged into a single `ToolArguments` schema. + * If multiple tools share the same parameter name, their schemas must be + * identical — otherwise an error is thrown to prevent silent conflicts. + * + * Throws if duplicate tool names are provided or if multiple tools define + * the same parameter name with different schemas. + */ +export function buildToolSchema(tools: ChatCompletionTool[]): JsonSchema { + const toolNames = tools.map((t) => t.function.name); + + // Validate tool name uniqueness + const nameSet = new Set(); + for (const name of toolNames) { + if (nameSet.has(name)) { + throw new Error( + `[tsfm compat] Duplicate tool name "${name}". Each tool must have a unique name.`, + ); + } + nameSet.add(name); + } + + // Build a merged arguments schema from all tools' parameters. + // Foundation Models requires fully specified schemas — no open-ended objects. + // Throw when multiple tools define the same property name with different schemas. + const mergedProperties: Record = {}; + const seenPropertySources = new Map(); + for (const tool of tools) { + const params = tool.function.parameters; + if (params && typeof params === "object" && params.properties) { + const props = params.properties as Record; + for (const [key, value] of Object.entries(props)) { + const prev = seenPropertySources.get(key); + if (prev !== undefined) { + const existingSchema = JSON.stringify(mergedProperties[key]); + const newSchema = JSON.stringify(value); + if (existingSchema !== newSchema) { + throw new Error( + `[tsfm compat] Tool parameter "${key}" is defined by both "${prev}" and "${tool.function.name}" ` + + `with different schemas. Rename one of the parameters to avoid conflicts.`, + ); + } + } + seenPropertySources.set(key, tool.function.name); + mergedProperties[key] = value; + } + } + } + + // The AFM schema parser requires nested objects to use $defs/$ref — + // inline nested objects cause hangs or Code=1041 rejections. + // + // x-order controls generation order. Generating type first lets the model + // commit to "tool_call" before filling out the object. tool_call comes + // before content so the model fills the structured call instead of dumping + // tool info into the text content field. + return { + $defs: { + ToolCall: { + title: "ToolCall", + type: "object", + required: ["name", "arguments"], + additionalProperties: false, + properties: { + name: { + type: "string", + enum: toolNames, + }, + arguments: { + $ref: "#/$defs/ToolArguments", + }, + }, + "x-order": ["name", "arguments"], + }, + ToolArguments: { + title: "ToolArguments", + type: "object", + properties: mergedProperties, + required: [] as string[], + additionalProperties: false, + "x-order": Object.keys(mergedProperties), + }, + }, + type: "object", + required: ["type"], + additionalProperties: false, + properties: { + type: { + type: "string", + enum: ["tool_call", "text"], + }, + tool_call: { + $ref: "#/$defs/ToolCall", + }, + content: { + type: "string", + }, + }, + }; +} + +/** + * Parses the model's structured output into a ToolParseResult. + */ +export function parseToolResponse(parsed: ToolModelOutput): ToolParseResult { + if (parsed.type === "tool_call") { + if (parsed.tool_call == null) { + console.warn( + `[tsfm compat] Model generated type "tool_call" but the tool_call field is missing. ` + + `Falling back to an empty text response.`, + ); + return { type: "text", content: "" }; + } + + const { name, arguments: args = {} } = parsed.tool_call; + + // Ensure arguments is an object before serializing — if the model + // returns a string or other primitive, wrap it to avoid malformed JSON. + const normalizedArgs = + args != null && typeof args === "object" && !Array.isArray(args) ? args : {}; + + return { + type: "tool_call", + toolCall: { + id: "call_" + randomUUID(), + type: "function", + function: { + name, + arguments: JSON.stringify(normalizedArgs), + }, + }, + }; + } + + return { + type: "text", + content: typeof parsed.content === "string" ? parsed.content : "", + }; +} diff --git a/src/compat/transcript.ts b/src/compat/transcript.ts new file mode 100644 index 0000000..63fe2da --- /dev/null +++ b/src/compat/transcript.ts @@ -0,0 +1,213 @@ +import { randomUUID } from "node:crypto"; +import type { JsonObject } from "../schema.js"; +import type { ChatCompletionMessageParam, ChatCompletionMessageToolCall } from "./types.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface TranscriptResult { + /** The native Apple Foundation Models transcript JSON string. */ + transcriptJson: string; + /** The text of the last user message, excluded from the transcript entries. */ + prompt: string; +} + +// --------------------------------------------------------------------------- +// Internal transcript shape +// --------------------------------------------------------------------------- + +interface TranscriptContentItem { + type: "text"; + text: string; + id: string; +} + +interface TranscriptEntry { + role: "instructions" | "user" | "response"; + id: string; + options?: JsonObject; + contents: TranscriptContentItem[]; +} + +interface NativeTranscript { + type: "FoundationModels.Transcript"; + version: 1; + transcript: { + entries: TranscriptEntry[]; + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract plain text from a message content field. */ +function extractText( + content: string | Array<{ type: string; text?: string }> | null | undefined, +): string { + if (content == null) return ""; + if (typeof content === "string") return content; + const unsupported = new Set(); + for (const part of content) { + if (part.type !== "text") { + unsupported.add(part.type); + } + } + for (const type of unsupported) { + console.warn( + `[tsfm compat] ${type} content parts are not supported by Apple Foundation Models and will be ignored.`, + ); + } + return content + .filter((part) => part.type === "text" && part.text != null) + .map((part) => part.text as string) + .join(""); +} + +/** Build a single transcript content item. */ +function makeContent(text: string): TranscriptContentItem { + return { type: "text", text, id: randomUUID() }; +} + +/** Build a transcript entry. */ +function makeEntry( + role: TranscriptEntry["role"], + text: string, + withOptions = false, +): TranscriptEntry { + const entry: TranscriptEntry = { + role, + id: randomUUID(), + contents: [makeContent(text)], + }; + if (withOptions) { + entry.options = {}; + } + return entry; +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +/** + * Convert a Chat-style messages array into the Foundation Models native + * transcript JSON format. + * + * The last user message is excluded from the transcript entries and returned + * separately as `prompt`. + */ +export function messagesToTranscript(messages: ChatCompletionMessageParam[]): TranscriptResult { + if (messages.length === 0) { + throw new Error("messages array must not be empty"); + } + + // When the last message is a tool result (standard tool-calling flow), append + // a synthetic user message summarizing the tool results so the standard + // processing can handle it. + let normalized = messages; + const last = messages[messages.length - 1]; + if (last.role === "tool") { + let toolStart = messages.length - 1; + while (toolStart > 0 && messages[toolStart - 1].role === "tool") { + toolStart--; + } + const toolMessages = messages.slice(toolStart); + const parts: string[] = []; + for (const msg of toolMessages) { + const toolMsg = msg as { + role: "tool"; + tool_call_id: string; + content: string | Array<{ type: string; text?: string }>; + }; + const content = extractText(toolMsg.content); + const toolName = resolveToolName(toolMsg.tool_call_id, messages); + parts.push( + toolName != null + ? `[Tool result for ${toolName}]: ${content}` + : `[Tool result]: ${content}`, + ); + } + normalized = [...messages, { role: "user" as const, content: parts.join("\n") }]; + } + + const lastMsg = normalized[normalized.length - 1]; + if (lastMsg.role !== "user") { + throw new Error(`Last message must have role "user", got "${lastMsg.role}"`); + } + + // Separate the last user message from the history + const history = normalized.slice(0, -1); + const prompt = extractText( + (lastMsg as { role: "user"; content: string | Array<{ type: string; text?: string }> }).content, + ); + + const entries: TranscriptEntry[] = []; + let seenSystemOrDeveloper = false; + + for (const msg of history) { + if (msg.role === "system" || msg.role === "developer") { + const text = extractText(msg.content); + if (!seenSystemOrDeveloper) { + entries.push(makeEntry("instructions", text)); + seenSystemOrDeveloper = true; + } else { + entries.push(makeEntry("user", `[System] ${text}`, true)); + } + } else if (msg.role === "user") { + const text = extractText(msg.content); + entries.push(makeEntry("user", text, true)); + } else if (msg.role === "assistant") { + let text: string; + if (msg.tool_calls && msg.tool_calls.length > 0) { + text = JSON.stringify(msg.tool_calls); + } else { + text = extractText(msg.content); + } + entries.push(makeEntry("response", text)); + } else if (msg.role === "tool") { + const toolMsg = msg as { + role: "tool"; + tool_call_id: string; + content: string | Array<{ type: string; text?: string }>; + }; + const content = extractText(toolMsg.content); + const toolName = resolveToolName(toolMsg.tool_call_id, history); + const text = + toolName != null + ? `[Tool result for ${toolName}]: ${content}` + : `[Tool result]: ${content}`; + entries.push(makeEntry("user", text, true)); + } + } + + const native: NativeTranscript = { + type: "FoundationModels.Transcript", + version: 1, + transcript: { entries }, + }; + + return { transcriptJson: JSON.stringify(native), prompt }; +} + +// --------------------------------------------------------------------------- +// Tool name resolution +// --------------------------------------------------------------------------- + +/** Scan backward through messages to find the tool name for a given call ID. */ +function resolveToolName( + toolCallId: string, + messages: ChatCompletionMessageParam[], +): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant" && msg.tool_calls) { + const match = (msg.tool_calls as ChatCompletionMessageToolCall[]).find( + (tc) => tc.id === toolCallId, + ); + if (match) return match.function.name; + } + } + return null; +} diff --git a/src/compat/types.ts b/src/compat/types.ts new file mode 100644 index 0000000..ccce861 --- /dev/null +++ b/src/compat/types.ts @@ -0,0 +1,213 @@ +// Chat Completions type definitions for the Apple Foundation Models compat layer. +// This is a pure types file with no runtime code. + +import type { JsonSchema, JsonObject } from "../schema.js"; + +// --------------------------------------------------------------------------- +// Message types +// --------------------------------------------------------------------------- + +export type ChatCompletionSystemMessageParam = { + role: "system"; + content: string | Array<{ type: "text"; text: string }>; + name?: string; +}; + +export type ChatCompletionDeveloperMessageParam = { + role: "developer"; + content: string | Array<{ type: "text"; text: string }>; + name?: string; +}; + +export type ChatCompletionContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } } + | { type: "input_audio"; input_audio: { data: string; format: string } } + | { type: "file"; file: { file_data?: string; file_id?: string; filename?: string } } + | { type: "refusal"; refusal: string }; + +export type ChatCompletionUserMessageParam = { + role: "user"; + content: string | Array; + name?: string; +}; + +export type ChatCompletionMessageToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type ChatCompletionAssistantMessageParam = { + role: "assistant"; + content?: string | Array | null; + tool_calls?: ChatCompletionMessageToolCall[]; + refusal?: string | null; + name?: string; +}; + +export type ChatCompletionToolMessageParam = { + role: "tool"; + content: string | Array<{ type: "text"; text: string }>; + tool_call_id: string; +}; + +export type ChatCompletionMessageParam = + | ChatCompletionSystemMessageParam + | ChatCompletionDeveloperMessageParam + | ChatCompletionUserMessageParam + | ChatCompletionAssistantMessageParam + | ChatCompletionToolMessageParam; + +// --------------------------------------------------------------------------- +// Tool types +// --------------------------------------------------------------------------- + +export type ChatCompletionTool = { + type: "function"; + function: { + name: string; + description?: string; + parameters?: JsonSchema; + strict?: boolean | null; + }; +}; + +// --------------------------------------------------------------------------- +// Response format +// --------------------------------------------------------------------------- + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { + type: "json_schema"; + json_schema: { + name: string; + description?: string; + schema?: JsonSchema; + strict?: boolean | null; + }; + }; + +// --------------------------------------------------------------------------- +// Request params +// --------------------------------------------------------------------------- + +/** + * Request params for `chat.completions.create()`. + * + * All standard params are accepted for type compatibility. Supported params are + * mapped to Foundation Models GenerationOptions; unsupported params are warned + * and silently ignored at runtime (see `mapParams()` and `UNSUPPORTED_PARAMS`). + */ +export type ChatCompletionCreateParams = { + // Required + messages: ChatCompletionMessageParam[]; + + // Supported params (model is optional here for convenience — this layer only + // supports one model so omitting it is safe) + model?: string; + temperature?: number | null; + max_tokens?: number | null; + max_completion_tokens?: number | null; + top_p?: number | null; + seed?: number | null; + stream?: boolean | null; + tools?: ChatCompletionTool[]; + response_format?: ResponseFormat; + + // Accepted but ignored (unsupported by Foundation Models) + n?: number | null; + stop?: string | string[] | null; + logprobs?: boolean | null; + top_logprobs?: number | null; + frequency_penalty?: number | null; + presence_penalty?: number | null; + logit_bias?: Record | null; + parallel_tool_calls?: boolean; + tool_choice?: "none" | "auto" | "required" | { type: "function"; function: { name: string } }; + service_tier?: string | null; + store?: boolean | null; + metadata?: Record | null; + prediction?: { type: "content"; content: string | Array<{ type: "text"; text: string }> }; + reasoning_effort?: string | null; + audio?: { voice: string; format: string } | null; + modalities?: string[] | null; + user?: string; + stream_options?: { include_usage?: boolean } | null; + verbosity?: string | null; + web_search_options?: { search_context_size?: string; user_location?: JsonObject } | null; + prompt_cache_key?: string; + prompt_cache_retention?: string | null; + safety_identifier?: string; + /** @deprecated Use `tools` and `tool_choice` instead. */ + function_call?: "none" | "auto" | { name: string }; + /** @deprecated Use `tools` instead. */ + functions?: Array<{ name: string; description?: string; parameters?: JsonSchema }>; +}; + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +export type ChatCompletionMessage = { + role: "assistant"; + content: string | null; + tool_calls?: ChatCompletionMessageToolCall[]; + refusal: string | null; +}; + +export type ChatCompletionChoice = { + index: number; + message: ChatCompletionMessage; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter"; +}; + +export type ChatCompletion = { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: ChatCompletionChoice[]; + usage: null; + system_fingerprint: null; +}; + +// --------------------------------------------------------------------------- +// Streaming types +// --------------------------------------------------------------------------- + +export type ChatCompletionChunkDelta = { + role?: "assistant"; + content?: string | null; + tool_calls?: Array<{ + index: number; + id?: string; + type?: "function"; + function?: { + name?: string; + arguments?: string; + }; + }>; + refusal?: string | null; +}; + +export type ChatCompletionChunkChoice = { + index: number; + delta: ChatCompletionChunkDelta; + finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null; +}; + +export type ChatCompletionChunk = { + id: string; + object: "chat.completion.chunk"; + created: number; + model: string; + choices: ChatCompletionChunkChoice[]; + usage: null; + system_fingerprint: null; +}; diff --git a/src/compat/utils.ts b/src/compat/utils.ts new file mode 100644 index 0000000..57d4905 --- /dev/null +++ b/src/compat/utils.ts @@ -0,0 +1,62 @@ +import type { JsonSchema, JsonObject } from "../schema.js"; + +/** + * Reorder JSON keys to match the property order defined in a JSON schema. + * Other AI APIs return keys in schema-defined order; Apple returns them in + * generation order. This normalizes the output for compatibility. + */ +export function reorderJson(json: string, schema: JsonSchema): string { + try { + const obj = JSON.parse(json); + return JSON.stringify(orderKeys(obj, schema)); + } catch { + return json; + } +} + +export function orderKeys(value: JsonObject[string], schema: JsonSchema): JsonObject[string] { + if (value == null || typeof value !== "object") return value; + + // Handle arrays: reorder keys inside each element using schema.items + if (Array.isArray(value)) { + const itemSchema = schema.items as JsonSchema | undefined; + if (itemSchema && typeof itemSchema === "object" && !Array.isArray(itemSchema)) { + return value.map((el) => orderKeys(el, itemSchema)) as JsonObject[]; + } + return value; + } + + const props = schema.properties as Record | undefined; + if (!props) return value; + + const obj = value as JsonObject; + const ordered: JsonObject = {}; + + // First, add keys in schema property order + for (const key of Object.keys(props)) { + if (key in obj) { + ordered[key] = orderKeys(obj[key], props[key]); + } + } + // Then any extra keys not in schema (shouldn't happen with strict schemas) + for (const key of Object.keys(obj)) { + if (!(key in ordered)) { + ordered[key] = obj[key]; + } + } + return ordered; +} + +export function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +export class CompatError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "CompatError"; + this.status = status; + } +} diff --git a/src/core.ts b/src/core.ts index 25413a0..e32fec1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -113,4 +113,8 @@ export class SystemLanguageModel { this._nativeModel = null; } } + + [Symbol.dispose](): void { + this.dispose(); + } } diff --git a/src/errors.ts b/src/errors.ts index 9c469d6..c53d73c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -97,6 +97,21 @@ export class InvalidGenerationSchemaError extends GenerationError { } } +/** + * The Apple Intelligence service (`generativeexperiencesd`) has crashed. + * Detected in `statusToError()` when UNKNOWN_ERROR details contain + * "SensitiveContentAnalysisML" or "ModelManagerError Code=1013". + */ +export class ServiceCrashedError extends GenerationError { + constructor(detail?: string) { + const recovery = + "The Apple Intelligence service has crashed. " + + "Restart it by running: launchctl kickstart -k gui/$(id -u)/com.apple.generativeexperiencesd"; + super(detail ? `${recovery}\n\nOriginal error: ${detail}` : recovery); + this.name = "ServiceCrashedError"; + } +} + export class ToolCallError extends FoundationModelsError { constructor( public readonly toolName: string, @@ -131,6 +146,19 @@ export function statusToError(status: number, detail?: string | null): Generatio case GenerationErrorCode.INVALID_SCHEMA: return new InvalidGenerationSchemaError(`Invalid schema${suffix}`); default: + if (status === GenerationErrorCode.UNKNOWN_ERROR && detail) { + if ( + detail.includes("SensitiveContentAnalysisML") || + detail.includes("ModelManagerError Code=1013") + ) { + return new ServiceCrashedError(detail); + } + if (detail.includes("ModelManagerError Code=1041")) { + return new InvalidGenerationSchemaError( + `The on-device model rejected the schema${suffix}`, + ); + } + } return new GenerationError(`Unknown error (code ${status})${suffix}`); } } diff --git a/src/index.ts b/src/index.ts index 5257b93..2999f72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,15 @@ export { export { LanguageModelSession } from "./session.js"; -export { Transcript } from "./transcript.js"; +export { + Transcript, + type TranscriptEntry, + type TranscriptContent, + type TranscriptTextContent, + type TranscriptStructuredContent, + type TranscriptToolCall, + type TranscriptEntryRole, +} from "./transcript.js"; export { GenerationSchema, @@ -17,6 +25,8 @@ export { GuideType, GeneratedContent, type PropertyType, + type JsonSchema, + type JsonObject, } from "./schema.js"; export { SamplingMode, type SamplingModeType, type GenerationOptions } from "./options.js"; @@ -37,5 +47,6 @@ export { ConcurrentRequestsError, RefusalError, InvalidGenerationSchemaError, + ServiceCrashedError, ToolCallError, } from "./errors.js"; diff --git a/src/options.ts b/src/options.ts index 0074006..f6d487e 100644 --- a/src/options.ts +++ b/src/options.ts @@ -44,10 +44,23 @@ export interface GenerationOptions { maximumResponseTokens?: number; } +interface SerializedSampling { + mode: string; + top_k?: number; + top_p?: number; + seed?: number; +} + +interface SerializedOptions { + temperature?: number; + maximum_response_tokens?: number; + sampling?: SerializedSampling | { mode: "greedy" }; +} + export function serializeOptions(options: GenerationOptions | undefined): string | null { if (!options) return null; - const obj: Record = {}; + const obj: SerializedOptions = {}; if (options.temperature !== undefined) obj.temperature = options.temperature; if (options.maximumResponseTokens !== undefined) { @@ -59,7 +72,7 @@ export function serializeOptions(options: GenerationOptions | undefined): string if (sampling.type === "greedy") { obj.sampling = { mode: "greedy" }; } else { - const r: Record = { mode: "random" }; + const r: SerializedSampling = { mode: "random" }; // Key names aligned with Python SDK: top_k, top_p if (sampling.top !== undefined) r.top_k = sampling.top; if (sampling.probabilityThreshold !== undefined) r.top_p = sampling.probabilityThreshold; diff --git a/src/schema.ts b/src/schema.ts index d1bf6a0..31f238a 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -10,6 +10,18 @@ import { statusToError } from "./errors.js"; export type PropertyType = "string" | "integer" | "number" | "boolean" | "array" | "object"; +type JsonPrimitive = string | number | boolean | null | undefined; + +/** A JSON Schema definition object. */ +export type JsonSchema = { + [key: string]: JsonSchema | JsonSchema[] | JsonPrimitive | JsonPrimitive[]; +}; + +/** An arbitrary parsed JSON object. */ +export type JsonObject = { + [key: string]: JsonObject | JsonObject[] | JsonPrimitive | JsonPrimitive[]; +}; + // --------------------------------------------------------------------------- // GuideType — mirrors Python's GuideType enum // --------------------------------------------------------------------------- @@ -221,7 +233,7 @@ export class GenerationSchema { } /** Serialize the schema to a plain object (mirrors Python's GenerationSchema.to_dict()). */ - toDict(): Record { + toDict(): JsonSchema { const errorCode = [0]; const pointer = getFunctions().FMGenerationSchemaGetJSONString( this._nativeSchema, @@ -239,29 +251,65 @@ export class GenerationSchema { // --------------------------------------------------------------------------- /** - * Normalize a plain JSON Schema object to the format required by Apple's - * Foundation Models C API. + * Normalize a JSON Schema object for the Foundation Models C API. * - * Three fields are injected if not already present: - * - `title` — required by the C API; defaults to `"Schema"` (an arbitrary - * placeholder; the value has no semantic effect on generation). - * - `additionalProperties: false` — required for strict schema conformance. - * - `x-order` — an array of property names that controls generation order; - * derived from `Object.keys(properties)` when omitted. + * The AFM schema parser requires every `object` node to have `title`, + * `properties`, `required`, `additionalProperties`, and `x-order`. This + * function recursively fills in missing keys with sensible defaults. Also + * recurses into `$defs` entries (used for nested objects via `$ref`). * * @internal */ -export function afmSchemaFormat(schema: Record): Record { - const properties = schema.properties; - const propertyOrder = - schema["x-order"] ?? - (properties && typeof properties === "object" ? Object.keys(properties) : []); - return { - title: "Schema", - additionalProperties: false, - ...schema, - "x-order": propertyOrder, - }; +export function afmSchemaFormat(schema: JsonSchema, isRoot = true): JsonSchema { + const result: JsonSchema = { ...schema }; + + // Recurse into $defs entries (Apple uses $defs/$ref for nested objects) + if (result.$defs && typeof result.$defs === "object") { + const defs = result.$defs as Record; + const normalized: Record = {}; + for (const [key, value] of Object.entries(defs)) { + normalized[key] = value && typeof value === "object" ? afmSchemaFormat(value, false) : value; + } + result.$defs = normalized; + } + + // Recurse into properties (skip $ref properties — they reference $defs) + if (result.properties && typeof result.properties === "object") { + const props = result.properties as Record; + const normalized: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (value && typeof value === "object" && "$ref" in value) { + normalized[key] = value; + } else { + normalized[key] = + value && typeof value === "object" ? afmSchemaFormat(value, false) : value; + } + } + result.properties = normalized; + } + + // Recurse into array items (e.g. { type: "array", items: { type: "object", ... } }) + if ( + result.items && + typeof result.items === "object" && + !Array.isArray(result.items) && + !("$ref" in (result.items as JsonSchema)) + ) { + result.items = afmSchemaFormat(result.items as JsonSchema, false); + } + + // Apple requires every object to have title, properties, required, additionalProperties, and x-order + if (result.type === "object") { + if (!result.title) result.title = isRoot ? "Schema" : "Object"; + if (!result.properties) result.properties = {}; + if (!result.required) result.required = []; + if (!("additionalProperties" in result)) result.additionalProperties = false; + if (!result["x-order"]) { + result["x-order"] = Object.keys(result.properties as object); + } + } + + return result; } // --------------------------------------------------------------------------- @@ -275,7 +323,7 @@ export class GeneratedContent { /** @internal */ _nativeContent: NativePointer; - private _parsed: Record | null = null; + private _parsed: JsonObject | null = null; /** @internal */ constructor(pointer: NativePointer) { @@ -302,7 +350,7 @@ export class GeneratedContent { } /** Returns the parsed JSON object. */ - toObject(): Record { + toObject(): JsonObject { if (!this._parsed) { this._parsed = JSON.parse(this.toJson()); } diff --git a/src/session.ts b/src/session.ts index 072097e..d8b09fe 100644 --- a/src/session.ts +++ b/src/session.ts @@ -11,36 +11,90 @@ import { } from "./bindings.js"; import { SystemLanguageModel } from "./core.js"; import { Tool } from "./tool.js"; -import { GenerationSchema, GeneratedContent, afmSchemaFormat } from "./schema.js"; +import { GenerationSchema, GeneratedContent, afmSchemaFormat, type JsonSchema } from "./schema.js"; import { GenerationOptions, serializeOptions } from "./options.js"; import { statusToError, FoundationModelsError } from "./errors.js"; import { Transcript } from "./transcript.js"; +/** Sentinel object passed to the constructor to skip the C API call. */ +const _FROM_POINTER = Symbol("fromPointer"); + const _sessionRegistry = new FinalizationRegistry((pointer: NativePointer) => { try { getFunctions().FMRelease(pointer); } catch {} }); +// Track live sessions so we can release them when the process exits. +// Orphaned native sessions can crash the Apple Intelligence safety service. +// Uses WeakRef so forgotten sessions can still be garbage-collected. +const _liveSessions = new Set>(); + +function _cleanupAllSessions(): void { + for (const ref of _liveSessions) { + try { + ref.deref()?.dispose(); + } catch {} + } + _liveSessions.clear(); +} + +let _exitHandlerInstalled = false; +function _installExitHandler(): void { + if (_exitHandlerInstalled) return; + _exitHandlerInstalled = true; + process.on("exit", _cleanupAllSessions); + // SIGINT (Ctrl+C) and SIGTERM (kill) don't trigger "exit" by default. + // Clean up native sessions, then re-raise the signal so the process + // terminates with the correct exit code / signal disposition. + // Use `once` so the handler removes itself before re-raising, avoiding a loop. + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + _cleanupAllSessions(); + process.kill(process.pid, signal); + }); + } +} + type ResponseCbArgs = [status: number, content: string, _length: number, userInfo: unknown]; type StructuredCbArgs = [status: number, contentRef: NativePointer, userInfo: unknown]; export class LanguageModelSession { /** @internal */ - _nativeSession: NativePointer | null; + _nativeSession: NativePointer | null = null; - transcript: Transcript; + private _transcript: Transcript | null = null; + private _weakRef: WeakRef | null = null; + + get transcript(): Transcript { + if (!this._transcript) throw new FoundationModelsError("Session not initialized"); + return this._transcript; + } private _activeTask: NativePointer | null = null; private _queue = Promise.resolve(); + /** Shared initialization for both constructor and fromTranscript. */ + private _init(pointer: NativePointer, transcript: Transcript): void { + this._nativeSession = pointer; + this._transcript = transcript; + _sessionRegistry.register(this, pointer, this); + this._weakRef = new WeakRef(this); + _liveSessions.add(this._weakRef); + _installExitHandler(); + } + constructor( - opts: { - instructions?: string; - model?: SystemLanguageModel; - tools?: Tool[]; - } = {}, + opts: + | { + instructions?: string; + model?: SystemLanguageModel; + tools?: Tool[]; + } + | typeof _FROM_POINTER = {}, ) { + if (opts === _FROM_POINTER) return; // shell instance — _init() called by fromTranscript + const fn = getFunctions(); const tools = opts.tools ?? []; tools.forEach((t) => t._register()); @@ -48,17 +102,15 @@ export class LanguageModelSession { const toolPointers = tools.map((t) => t._nativeTool); const toolPointersArg = tools.length > 0 ? koffi.as(toolPointers, "void **") : null; - this._nativeSession = fn.FMLanguageModelSessionCreateFromSystemLanguageModel( + const pointer = fn.FMLanguageModelSessionCreateFromSystemLanguageModel( opts.model?._nativeModel ?? null, opts.instructions ?? null, toolPointersArg, tools.length, ); - if (!this._nativeSession) - throw new FoundationModelsError("Failed to create LanguageModelSession"); - this.transcript = new Transcript(this._nativeSession); - _sessionRegistry.register(this, this._nativeSession, this); + if (!pointer) throw new FoundationModelsError("Failed to create LanguageModelSession"); + this._init(pointer, new Transcript(pointer)); } /** @@ -87,20 +139,11 @@ export class LanguageModelSession { if (!pointer) throw new FoundationModelsError("Failed to create session from transcript"); - // Object.create bypasses the constructor, which always calls - // FMLanguageModelSessionCreateFromSystemLanguageModel. fromTranscript needs - // FMLanguageModelSessionCreateFromTranscript instead, so we allocate the - // instance shell manually and assign every field the constructor would set. - // If new instance fields are added to the constructor, add them here too. - const session: LanguageModelSession = Object.create(LanguageModelSession.prototype); - session._nativeSession = pointer; - session._activeTask = null; - session._queue = Promise.resolve(); - session.transcript = transcript; + const session = new LanguageModelSession(_FROM_POINTER); + session._init(pointer, transcript); // Update the transcript's native session so future toJson() calls read // from the new session rather than the original deserialized transcript. transcript._updateNativeSession(pointer); - _sessionRegistry.register(session, pointer, session); return session; } @@ -168,7 +211,7 @@ export class LanguageModelSession { */ async respondWithJsonSchema( prompt: string, - jsonSchema: Record, + jsonSchema: JsonSchema, opts: { options?: GenerationOptions } = {}, ): Promise { return this._enqueue(() => this._respondWithJsonSchema(prompt, jsonSchema, opts.options)); @@ -279,6 +322,10 @@ export class LanguageModelSession { } dispose(): void { + if (this._weakRef) { + _liveSessions.delete(this._weakRef); + this._weakRef = null; + } if (this._nativeSession) { _sessionRegistry.unregister(this); getFunctions().FMRelease(this._nativeSession); @@ -286,6 +333,10 @@ export class LanguageModelSession { } } + [Symbol.dispose](): void { + this.dispose(); + } + // ------------------------------------------------------------------------- // Private implementation // ------------------------------------------------------------------------- @@ -385,7 +436,7 @@ export class LanguageModelSession { private _respondWithJsonSchema( prompt: string, - jsonSchema: Record, + jsonSchema: JsonSchema, options: GenerationOptions | undefined, ): Promise { const fn = getFunctions(); diff --git a/src/tool.ts b/src/tool.ts index 1023728..669b1e0 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -76,16 +76,23 @@ export abstract class Tool { // it stays registered for the full lifetime of the tool because the model // may invoke this tool multiple times within a single session. this._callback = koffi.register((contentRef: NativePointer, callId: number) => { - const content = new GeneratedContent(contentRef); - this.call(content) - .then((result) => { - fn.FMBridgedToolFinishCall(this._nativeTool, callId, result); - }) - .catch((err: unknown) => { - const cause = err instanceof Error ? err : new Error(String(err)); - const toolErr = new ToolCallError(this.name, cause); - fn.FMBridgedToolFinishCall(this._nativeTool, callId, toolErr.message); - }); + try { + const content = new GeneratedContent(contentRef); + this.call(content) + .then((result) => { + fn.FMBridgedToolFinishCall(this._nativeTool, callId, result); + }) + .catch((err: unknown) => { + const cause = err instanceof Error ? err : new Error(String(err)); + const toolErr = new ToolCallError(this.name, cause); + fn.FMBridgedToolFinishCall(this._nativeTool, callId, toolErr.message); + }); + } catch (err: unknown) { + // If anything throws synchronously (e.g. GeneratedContent construction), + // we must still finish the call or the session hangs forever. + const msg = err instanceof Error ? err.message : String(err); + fn.FMBridgedToolFinishCall(this._nativeTool, callId, `Tool callback error: ${msg}`); + } }, koffi.pointer(ToolCallbackProto)); const errorCode = [0]; @@ -128,4 +135,8 @@ export abstract class Tool { this._nativeTool = null; } } + + [Symbol.dispose](): void { + this.dispose(); + } } diff --git a/src/transcript.ts b/src/transcript.ts index a4e0d07..282b2f1 100644 --- a/src/transcript.ts +++ b/src/transcript.ts @@ -1,5 +1,45 @@ import { getFunctions, decodeAndFreeString, type NativePointer } from "./bindings.js"; import { statusToError, FoundationModelsError } from "./errors.js"; +import type { JsonSchema, JsonObject } from "./schema.js"; + +export type TranscriptEntryRole = "instructions" | "user" | "response" | "tool"; + +export interface TranscriptTextContent { + type: "text"; + text: string; + id: string; +} + +export interface TranscriptStructuredContent { + type: "structure"; + id: string; + structure: { source: string; content: JsonObject }; +} + +export type TranscriptContent = TranscriptTextContent | TranscriptStructuredContent; + +export interface TranscriptToolCall { + id: string; + name: string; + arguments: string; +} + +export interface TranscriptEntry { + id: string; + role: TranscriptEntryRole; + contents?: TranscriptContent[]; + // instructions-specific + tools?: JsonObject[]; + // user-specific + options?: JsonObject; + responseFormat?: JsonSchema; + // response-specific + toolCalls?: TranscriptToolCall[]; + assets?: string[]; + // tool-specific + toolName?: string; + toolCallID?: string; +} export class Transcript { /** @internal raw session pointer — backs the live session's native handle */ @@ -39,10 +79,17 @@ export class Transcript { } /** Export the transcript as a parsed dictionary (mirrors Python's Transcript.to_dict()). */ - toDict(): Record { + toDict(): JsonObject { return JSON.parse(this.toJson()); } + /** Return the typed transcript entries from the native JSON. */ + entries(): TranscriptEntry[] { + const data = JSON.parse(this.toJson()); + const entries = data?.transcript?.entries; + return Array.isArray(entries) ? entries : []; + } + /** Deserialize a previously exported transcript JSON string. */ static fromJson(json: string): Transcript { const fn = getFunctions(); @@ -55,7 +102,7 @@ export class Transcript { } /** Deserialize a transcript from a dictionary (mirrors Python's Transcript.from_dict()). */ - static fromDict(dict: Record): Transcript { + static fromDict(dict: JsonObject): Transcript { return Transcript.fromJson(JSON.stringify(dict)); } } diff --git a/tests/integration/basic.test.ts b/tests/integration/basic.test.ts index f9db41d..d1cc996 100644 --- a/tests/integration/basic.test.ts +++ b/tests/integration/basic.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { SystemLanguageModel, LanguageModelSession } from "../../src/index.js"; const model = new SystemLanguageModel(); -const { available } = model.isAvailable(); +const { available } = await model.waitUntilAvailable(5_000); const describeIfAvailable = available ? describe : describe.skip; afterAll(() => model.dispose()); diff --git a/tests/integration/compat.test.ts b/tests/integration/compat.test.ts new file mode 100644 index 0000000..cad7d54 --- /dev/null +++ b/tests/integration/compat.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, afterAll } from "vitest"; +import Client from "../../src/compat/index.js"; +import { retryAttempts } from "./helpers/retry.js"; + +const client = new Client(); +afterAll(() => client.close()); + +describe("Chat API integration", () => { + it("basic text generation", async () => { + const response = await client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [ + { role: "system", content: "Always respond with exactly one word." }, + { role: "user", content: "What color is the sky?" }, + ], + }); + + expect(response.object).toBe("chat.completion"); + expect(response.choices).toHaveLength(1); + expect(response.choices[0].message.role).toBe("assistant"); + expect(typeof response.choices[0].message.content).toBe("string"); + expect(response.choices[0].finish_reason).toBe("stop"); + expect(response.id).toMatch(/^chatcmpl-/); + expect(response.usage).toBeNull(); + expect(response.system_fingerprint).toBeNull(); + }); + + it("multi-turn conversation", async () => { + const response = await client.chat.completions.create({ + messages: [ + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], + }); + + const content = response.choices[0].message.content ?? ""; + expect(content).toContain("12"); + }); + + it("streaming", async () => { + const stream = await client.chat.completions.create({ + messages: [ + { role: "system", content: "You are a math tutor. Answer concisely." }, + { role: "user", content: "What is 10 + 5?" }, + ], + stream: true, + }); + + const chunks: string[] = []; + for await (const chunk of stream) { + if (chunk.choices[0].delta.content) { + chunks.push(chunk.choices[0].delta.content); + } + } + + expect(chunks.length).toBeGreaterThan(0); + const full = chunks.join(""); + expect(full).toContain("15"); + }); + + it("structured output with json_schema", async () => { + const response = await client.chat.completions.create({ + messages: [{ role: "user", content: "Extract: John is 30 years old" }], + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + }, + required: ["name", "age"], + }, + }, + }, + }); + + const parsed = JSON.parse(response.choices[0].message.content!); + expect(parsed.name).toContain("John"); + expect(parsed.age).toBe(30); + }); + + it("generation options (temperature, max_tokens)", async () => { + const response = await client.chat.completions.create({ + messages: [{ role: "user", content: "Say hello" }], + temperature: 0, + max_tokens: 50, + }); + + expect(typeof response.choices[0].message.content).toBe("string"); + }); + + it( + "multi-turn tool calling flow (user → tool_call → tool_result → response)", + { timeout: 40_000 }, + async () => { + const tools = [ + { + type: "function" as const, + function: { + name: "lookup_code", + description: "Looks up a secret code for a given key. Always use this tool.", + parameters: { + type: "object", + properties: { key: { type: "string" } }, + required: ["key"], + }, + }, + }, + ]; + + const { successes } = await retryAttempts( + async () => { + const localClient = new Client(); + try { + // Step 1: Get model to call the tool + const step1 = await localClient.chat.completions.create({ + messages: [ + { + role: "system", + content: + "You MUST call the lookup_code tool when asked about codes. Never guess.", + }, + { + role: "user", + content: 'Use the lookup_code tool with key "alpha".', + }, + ], + tools, + }); + + if (step1.choices[0].finish_reason !== "tool_calls") { + return { success: false, detail: "Model did not call tool" }; + } + + const toolCall = step1.choices[0].message.tool_calls![0]; + + // Step 2: Send tool result back and get final response + const step2 = await localClient.chat.completions.create({ + messages: [ + { + role: "system", + content: + "You MUST call the lookup_code tool when asked about codes. Never guess.", + }, + { + role: "user", + content: 'Use the lookup_code tool with key "alpha".', + }, + { + role: "assistant", + content: null, + tool_calls: [toolCall], + }, + { + role: "tool", + tool_call_id: toolCall.id, + content: "XRAY-7749", + }, + ], + tools, + }); + + const reply = step2.choices[0].message.content ?? ""; + if (reply.includes("XRAY-7749")) { + return { success: true, detail: `reply: "${reply.slice(0, 80)}"` }; + } + return { success: false, detail: `reply missing code: "${reply.slice(0, 100)}"` }; + } finally { + localClient.close(); + } + }, + { maxAttempts: 5, requiredSuccesses: 1, label: "compat multi-turn tools" }, + ); + + expect(successes).toBeGreaterThanOrEqual(1); + }, + ); + + it("concurrent API calls serialize correctly", async () => { + const [r1, r2] = await Promise.all([ + client.chat.completions.create({ + messages: [{ role: "user", content: "Say just the word: alpha" }], + }), + client.chat.completions.create({ + messages: [{ role: "user", content: "Say just the word: beta" }], + }), + ]); + + // Both should complete successfully with distinct content + expect(r1.choices[0].finish_reason).toBe("stop"); + expect(r2.choices[0].finish_reason).toBe("stop"); + expect(typeof r1.choices[0].message.content).toBe("string"); + expect(typeof r2.choices[0].message.content).toBe("string"); + }); +}); diff --git a/tests/integration/helpers/retry.ts b/tests/integration/helpers/retry.ts new file mode 100644 index 0000000..422b1a5 --- /dev/null +++ b/tests/integration/helpers/retry.ts @@ -0,0 +1,66 @@ +/** + * Retry helper for flaky on-device model integration tests. + * + * The on-device model can be unreliable — it sometimes responds with text + * instead of calling a tool, or times out. This helper runs an attempt + * function multiple times and requires a minimum number of successes. + */ +export interface RetryOptions { + /** Maximum number of attempts (default: 5). */ + maxAttempts?: number; + /** Minimum successes required to pass (default: 1). */ + requiredSuccesses?: number; + /** Delay between attempts in ms (default: 2000). */ + delayMs?: number; + /** Label for log messages. */ + label?: string; +} + +export interface AttemptResult { + success: boolean; + detail?: string; +} + +/** + * Run `attemptFn` up to `maxAttempts` times, requiring `requiredSuccesses` + * successes. Returns the total successes count. Logs progress to console. + */ +export async function retryAttempts( + attemptFn: () => Promise, + opts: RetryOptions = {}, +): Promise<{ successes: number; failures: number }> { + const { maxAttempts = 5, requiredSuccesses = 1, delayMs = 2_000, label = "retry" } = opts; + + let successes = 0; + let failures = 0; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const result = await attemptFn(); + if (result.success) { + successes++; + console.log( + `[${label}] attempt ${attempt} succeeded (${successes}/${requiredSuccesses})${result.detail ? `: ${result.detail}` : ""}`, + ); + } else { + failures++; + console.log( + `[${label}] attempt ${attempt} failed${result.detail ? `: ${result.detail}` : ""}`, + ); + } + } catch (err) { + failures++; + console.log(`[${label}] attempt ${attempt} threw: ${(err as Error).message}`); + } + + if (successes >= requiredSuccesses) break; + if (attempt < maxAttempts) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + + console.log( + `[${label}] result: ${successes} successes, ${failures} failures out of ${successes + failures} attempts`, + ); + return { successes, failures }; +} diff --git a/tests/integration/responses.test.ts b/tests/integration/responses.test.ts new file mode 100644 index 0000000..7165f56 --- /dev/null +++ b/tests/integration/responses.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, afterAll } from "vitest"; +import Client from "../../src/compat/index.js"; +import type { Response, ResponseStreamEvent } from "../../src/compat/responses-types.js"; + +const client = new Client(); +afterAll(() => client.close()); + +describe("Responses API — side-by-side with Chat Completions", () => { + // ----------------------------------------------------------------------- + // Basic text generation + // ----------------------------------------------------------------------- + + it("both APIs produce text for a simple prompt", async () => { + const [chat, responses] = await Promise.all([ + client.chat.completions.create({ + model: "SystemLanguageModel", + messages: [ + { role: "system", content: "Always respond with exactly one word." }, + { role: "user", content: "What color is the sky?" }, + ], + }), + client.responses.create({ + instructions: "Always respond with exactly one word.", + input: "What color is the sky?", + }), + ]); + + // Chat Completions shape + expect(chat.object).toBe("chat.completion"); + expect(chat.choices).toHaveLength(1); + expect(chat.choices[0].message.role).toBe("assistant"); + expect(typeof chat.choices[0].message.content).toBe("string"); + expect(chat.choices[0].finish_reason).toBe("stop"); + + // Responses shape + expect(responses.object).toBe("response"); + expect(responses.output).toHaveLength(1); + expect(responses.output[0].type).toBe("message"); + expect(responses.status).toBe("completed"); + expect(typeof responses.output_text).toBe("string"); + expect(responses.output_text.length).toBeGreaterThan(0); + }); + + // ----------------------------------------------------------------------- + // Multi-turn conversation + // ----------------------------------------------------------------------- + + it("both APIs handle multi-turn correctly", async () => { + const [chat, responses] = await Promise.all([ + client.chat.completions.create({ + messages: [ + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], + }), + client.responses.create({ + input: [ + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "Multiply that by 3" }, + ], + }), + ]); + + const chatContent = chat.choices[0].message.content ?? ""; + expect(chatContent).toContain("12"); + + expect(responses.output_text).toContain("12"); + }); + + // ----------------------------------------------------------------------- + // Streaming + // ----------------------------------------------------------------------- + + it("both APIs stream text", async () => { + const prompt = "What is 10 + 5?"; + const system = "You are a math tutor. Answer concisely."; + + const [chatStream, responsesStream] = await Promise.all([ + client.chat.completions.create({ + messages: [ + { role: "system", content: system }, + { role: "user", content: prompt }, + ], + stream: true, + }), + client.responses.create({ + instructions: system, + input: prompt, + stream: true, + }), + ]); + + // Collect Chat Completions chunks + const chatChunks: string[] = []; + for await (const chunk of chatStream) { + if (chunk.choices[0].delta.content) { + chatChunks.push(chunk.choices[0].delta.content); + } + } + const chatFull = chatChunks.join(""); + + // Collect Responses stream events + const responsesDeltas: string[] = []; + const eventTypes = new Set(); + for await (const event of responsesStream) { + eventTypes.add(event.type); + if (event.type === "response.output_text.delta") { + responsesDeltas.push(event.delta); + } + } + const responsesFull = responsesDeltas.join(""); + + // Both should mention 15 + expect(chatFull).toContain("15"); + expect(responsesFull).toContain("15"); + + // Chat Completions streams chunks + expect(chatChunks.length).toBeGreaterThan(0); + + // Responses stream emits the proper lifecycle events + expect(eventTypes).toContain("response.created"); + expect(eventTypes).toContain("response.in_progress"); + expect(eventTypes).toContain("response.output_text.delta"); + expect(eventTypes).toContain("response.output_text.done"); + expect(eventTypes).toContain("response.completed"); + }); + + // ----------------------------------------------------------------------- + // Structured output + // ----------------------------------------------------------------------- + + it("both APIs produce structured output from the same schema", async () => { + const schema = { + type: "object" as const, + properties: { + name: { type: "string" }, + age: { type: "integer" }, + }, + required: ["name", "age"], + }; + + const [chat, responses] = await Promise.all([ + client.chat.completions.create({ + messages: [{ role: "user", content: "Extract: John is 30 years old" }], + response_format: { + type: "json_schema", + json_schema: { name: "Person", schema }, + }, + }), + client.responses.create({ + input: "Extract: John is 30 years old", + text: { + format: { + type: "json_schema", + name: "Person", + schema, + }, + }, + }), + ]); + + const chatParsed = JSON.parse(chat.choices[0].message.content!); + expect(chatParsed.name).toContain("John"); + expect(chatParsed.age).toBe(30); + + const responsesParsed = JSON.parse(responses.output_text); + expect(responsesParsed.name).toContain("John"); + expect(responsesParsed.age).toBe(30); + }); + + // ----------------------------------------------------------------------- + // Generation options + // ----------------------------------------------------------------------- + + it("both APIs accept temperature and max tokens", async () => { + const [chat, responses] = await Promise.all([ + client.chat.completions.create({ + messages: [{ role: "user", content: "Say hello" }], + temperature: 0, + max_tokens: 50, + }), + client.responses.create({ + input: "Say hello", + temperature: 0, + max_output_tokens: 50, + }), + ]); + + expect(typeof chat.choices[0].message.content).toBe("string"); + expect(typeof responses.output_text).toBe("string"); + }); +}); + +// --------------------------------------------------------------------------- +// Responses-only tests (features unique to the Responses API shape) +// --------------------------------------------------------------------------- + +describe("Responses API — standalone", () => { + it("response object has correct shape", async () => { + const response = await client.responses.create({ + input: "Hello", + }); + + expect(response.id).toMatch(/^resp_/); + expect(response.object).toBe("response"); + expect(typeof response.created_at).toBe("number"); + expect(response.model).toBe("SystemLanguageModel"); + expect(response.status).toBe("completed"); + expect(response.error).toBeNull(); + expect(response.incomplete_details).toBeNull(); + expect(response.usage).toBeNull(); + expect(response.tools).toEqual([]); + }); + + it("string input produces a text response", async () => { + const response = await client.responses.create({ + input: "Say the word hello.", + }); + + expect(response.status).toBe("completed"); + expect(response.output).toHaveLength(1); + expect(response.output[0].type).toBe("message"); + expect(response.output_text.length).toBeGreaterThan(0); + }); + + it("instructions are reflected in the response object", async () => { + const response = await client.responses.create({ + instructions: "Always respond with exactly one word.", + input: "Say hello", + }); + + expect(response.instructions).toBe("Always respond with exactly one word."); + expect(response.status).toBe("completed"); + expect(response.output_text.length).toBeGreaterThan(0); + }); + + it("streaming yields sequential sequence_numbers", async () => { + const stream = await client.responses.create({ + input: "Count to 3", + stream: true, + }); + + const seqNums: number[] = []; + let lastResponse: Response | null = null; + + for await (const event of stream) { + seqNums.push(event.sequence_number); + if (event.type === "response.completed") { + lastResponse = event.response; + } + } + + // Sequence numbers are sequential starting from 0 + for (let i = 0; i < seqNums.length; i++) { + expect(seqNums[i]).toBe(i); + } + + // Final event has the completed response + expect(lastResponse).not.toBeNull(); + expect(lastResponse!.status).toBe("completed"); + expect(lastResponse!.output).toHaveLength(1); + expect(lastResponse!.output_text.length).toBeGreaterThan(0); + }); + + it("streaming structured output works", async () => { + const stream = await client.responses.create({ + input: "Extract: Alice is 25", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + }, + required: ["name", "age"], + }, + }, + }, + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + // Should have lifecycle events + const types = events.map((e) => e.type); + expect(types[0]).toBe("response.created"); + expect(types[1]).toBe("response.in_progress"); + expect(types[types.length - 1]).toBe("response.completed"); + + // The completed response should have valid JSON + const completed = events.find((e) => e.type === "response.completed"); + expect(completed).toBeDefined(); + const response = (completed as { response: Response }).response; + const parsed = JSON.parse(response.output_text); + expect(parsed.name).toContain("Alice"); + expect(parsed.age).toBe(25); + }); +}); diff --git a/tests/integration/streaming.test.ts b/tests/integration/streaming.test.ts index 96e8ceb..903eb31 100644 --- a/tests/integration/streaming.test.ts +++ b/tests/integration/streaming.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { SystemLanguageModel, LanguageModelSession } from "../../src/index.js"; const model = new SystemLanguageModel(); -const { available } = model.isAvailable(); +const { available } = await model.waitUntilAvailable(5_000); const describeIfAvailable = available ? describe : describe.skip; afterAll(() => model.dispose()); diff --git a/tests/integration/structured-output.test.ts b/tests/integration/structured-output.test.ts index 858fb11..1af852e 100644 --- a/tests/integration/structured-output.test.ts +++ b/tests/integration/structured-output.test.ts @@ -7,7 +7,7 @@ import { } from "../../src/index.js"; const model = new SystemLanguageModel(); -const { available } = model.isAvailable(); +const { available } = await model.waitUntilAvailable(5_000); const describeIfAvailable = available ? describe : describe.skip; afterAll(() => model.dispose()); diff --git a/tests/integration/tools.test.ts b/tests/integration/tools.test.ts index bb1d739..90c0d60 100644 --- a/tests/integration/tools.test.ts +++ b/tests/integration/tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterAll } from "vitest"; +import { describe, it, expect } from "vitest"; import { SystemLanguageModel, LanguageModelSession, @@ -6,37 +6,90 @@ import { GeneratedContent, Tool, } from "../../src/index.js"; +import { retryAttempts } from "./helpers/retry.js"; -class AddTool extends Tool { - readonly name = "add"; - readonly description = "Adds two numbers and returns the sum."; - readonly argumentsSchema = new GenerationSchema("AddParams", "Addition parameters") - .property("a", "number", { description: "First number" }) - .property("b", "number", { description: "Second number" }); +/** + * A tool that returns a secret code the model cannot know without calling it. + * This guarantees the assertion fails if the model skips the tool. + */ +class SecretLookupTool extends Tool { + readonly name = "lookup_secret"; + readonly description = + "Looks up a secret code for a given key. Always use this tool when asked about secret codes."; + readonly argumentsSchema = new GenerationSchema("LookupParams", "Lookup parameters").property( + "key", + "string", + { description: "The key to look up" }, + ); + + called = false; + calledAt = 0; + returnedValue = ""; async call(args: GeneratedContent): Promise { - const a = args.value("a"); - const b = args.value("b"); - return String(a + b); + this.called = true; + this.calledAt = Date.now(); + const key = args.value("key"); + this.returnedValue = key === "alpha" ? "XRAY-7749" : "UNKNOWN"; + console.log( + `[tools test] tool.call() invoked with key="${key}", returning "${this.returnedValue}"`, + ); + return this.returnedValue; } } -const model = new SystemLanguageModel(); -const { available } = model.isAvailable(); +// Check availability once — used to skip the suite if model is unavailable. +const checkModel = new SystemLanguageModel(); +const { available } = await checkModel.waitUntilAvailable(5_000); +checkModel.dispose(); const describeIfAvailable = available ? describe : describe.skip; -afterAll(() => model.dispose()); - describeIfAvailable("tools (integration)", () => { - it("invokes a tool and returns result", async () => { - const tool = new AddTool(); - const session = new LanguageModelSession({ - instructions: "Use the add tool to answer math questions. Be concise.", - tools: [tool], - }); - const reply = await session.respond("What is 7 + 3?"); - expect(reply).toContain("10"); - session.dispose(); - tool.dispose(); - }, 120_000); + it("invokes a tool and includes its result", { timeout: 40_000 }, async () => { + const { successes } = await retryAttempts( + async () => { + const model = new SystemLanguageModel(); + const tool = new SecretLookupTool(); + const session = new LanguageModelSession({ + model, + instructions: + "You have access to a lookup_secret tool. You MUST call it when asked about secret codes. " + + "Do NOT guess or make up codes. Always call the tool first, then reply with only the code.", + tools: [tool], + }); + + try { + const reply = await Promise.race([ + session.respond( + 'Use the lookup_secret tool to find the secret code for key "alpha". ' + + "Do not guess — call the tool.", + ), + new Promise((_, reject) => { + setTimeout(() => { + session.cancel(); + reject(new Error("Attempt timed out")); + }, 5_000); + }), + ]); + + if (tool.called && reply.includes("XRAY-7749")) { + return { success: true, detail: `reply: "${reply.slice(0, 80)}"` }; + } + return { + success: false, + detail: tool.called + ? `tool called but reply missing code: "${reply.slice(0, 100)}"` + : `tool not called: "${reply.slice(0, 100)}"`, + }; + } finally { + session.dispose(); + tool.dispose(); + model.dispose(); + } + }, + { maxAttempts: 5, requiredSuccesses: 1, label: "tools test" }, + ); + + expect(successes).toBeGreaterThanOrEqual(1); + }); }); diff --git a/tests/integration/transcript.test.ts b/tests/integration/transcript.test.ts index 067174e..b8e7ab1 100644 --- a/tests/integration/transcript.test.ts +++ b/tests/integration/transcript.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll } from "vitest"; import { SystemLanguageModel, LanguageModelSession, Transcript } from "../../src/index.js"; const model = new SystemLanguageModel(); -const { available } = model.isAvailable(); +const { available } = await model.waitUntilAvailable(5_000); const describeIfAvailable = available ? describe : describe.skip; afterAll(() => model.dispose()); diff --git a/tests/integration/vitest.config.ts b/tests/integration/vitest.config.ts deleted file mode 100644 index d0ff45a..0000000 --- a/tests/integration/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - include: ["tests/integration/**/*.test.ts"], - pool: "forks", - fileParallelism: false, - testTimeout: 120_000, - }, -}); diff --git a/tests/unit/compat/index.test.ts b/tests/unit/compat/index.test.ts new file mode 100644 index 0000000..87bc2d7 --- /dev/null +++ b/tests/unit/compat/index.test.ts @@ -0,0 +1,866 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockFunctions } from "../helpers/mock-bindings.js"; + +// --------------------------------------------------------------------------- +// Hoisted mocks — must run before any import +// --------------------------------------------------------------------------- + +const { decodeAndFreeStringMock } = vi.hoisted(() => { + globalThis.FinalizationRegistry = class MockFinalizationRegistry { + constructor(_callback: unknown) {} + register() {} + unregister() {} + } as unknown as typeof FinalizationRegistry; + + return { + decodeAndFreeStringMock: vi.fn((_pointer: unknown): string | null => { + if (!_pointer) return null; + return '{"key":"value"}'; + }), + }; +}); + +const mockFns = createMockFunctions(); + +let lastRegisteredCallback: ((...args: unknown[]) => void) | null = null; + +vi.mock("koffi", () => ({ + default: { + register: vi.fn((cb: (...args: unknown[]) => void, _proto: unknown) => { + lastRegisteredCallback = cb; + return "mock-cb-pointer"; + }), + unregister: vi.fn(), + as: vi.fn((_arr: unknown[], _type: string) => "mock-arr-pointer"), + pointer: vi.fn((_proto: unknown) => "mock-proto-pointer"), + }, +})); + +vi.mock("../../../src/bindings.js", () => ({ + getFunctions: () => mockFns, + decodeAndFreeString: decodeAndFreeStringMock, + unregisterCallback: vi.fn(), + ResponseCallbackProto: "ResponseCallbackProto", + StructuredResponseCallbackProto: "StructuredResponseCallbackProto", +})); + +vi.mock("../../../src/tool.js", () => ({ + Tool: class MockTool { + _nativeTool = "mock-tool-pointer"; + _register() {} + }, +})); + +import Client, { MODEL_DEFAULT } from "../../../src/compat/index.js"; +import type { ChatCompletionChunk } from "../../../src/compat/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function simulateRespondSuccess(text: string) { + mockFns.FMLanguageModelSessionRespond.mockImplementation((..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(0, text, text.length, null); + }, 0); + return "mock-task-pointer"; + }); +} + +function simulateRespondError(status: number, msg: string) { + mockFns.FMLanguageModelSessionRespond.mockImplementation((..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(status, msg, msg.length, null); + }, 0); + return "mock-task-pointer"; + }); +} + +function simulateStreamSuccess(chunks: string[]) { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + let cumulative = ""; + let i = 0; + function next() { + if (i < chunks.length) { + cumulative += chunks[i]; + i++; + setTimeout(() => { + lastRegisteredCallback?.(0, cumulative, cumulative.length, null); + next(); + }, 0); + } else { + setTimeout(() => { + lastRegisteredCallback?.(0, null, 0, null); + }, 0); + } + } + next(); + }, + ); +} + +function simulateStructuredSuccess(jsonObj: Record) { + const jsonStr = JSON.stringify(jsonObj); + mockFns.FMLanguageModelSessionRespondWithSchemaFromJSON.mockImplementation( + (..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(0, "mock-content-pointer", null); + }, 0); + return "mock-task-pointer"; + }, + ); + mockFns.FMGeneratedContentGetJSONString.mockReturnValue("mock-json-pointer"); + decodeAndFreeStringMock.mockImplementation((pointer: unknown) => { + if (!pointer) return null; + return jsonStr; + }); +} + +function simulateStreamError(status: number, msg: string) { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + setTimeout(() => { + lastRegisteredCallback?.(status, msg, msg.length, null); + }, 0); + }, + ); +} + +const basicMessages = [{ role: "user" as const, content: "Hello" }]; + +const sampleTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + lastRegisteredCallback = null; + decodeAndFreeStringMock.mockImplementation((_pointer: unknown): string | null => { + if (!_pointer) return null; + return '{"key":"value"}'; + }); +}); + +describe("Chat API compat layer", () => { + describe("exports", () => { + it("exports MODEL_DEFAULT constant", () => { + expect(MODEL_DEFAULT).toBe("SystemLanguageModel"); + }); + + it("default export is the Client class", () => { + const client = new Client(); + expect(client).toBeInstanceOf(Client); + client.close(); + }); + }); + + describe("structure", () => { + it("has chat.completions.create method", () => { + const client = new Client(); + expect(client.chat).toBeDefined(); + expect(client.chat.completions).toBeDefined(); + expect(typeof client.chat.completions.create).toBe("function"); + client.close(); + }); + }); + + describe("create — non-streaming", () => { + it("throws on empty messages", async () => { + const client = new Client(); + await expect(client.chat.completions.create({ messages: [] })).rejects.toThrow( + "messages array must not be empty", + ); + client.close(); + }); + + it("returns correct ChatCompletion shape", async () => { + simulateRespondSuccess("Hello from Apple Intelligence"); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + }); + + expect(result.object).toBe("chat.completion"); + expect(result.model).toBe("SystemLanguageModel"); + expect(result.choices).toHaveLength(1); + expect(result.choices[0].index).toBe(0); + expect(result.choices[0].finish_reason).toBe("stop"); + expect(result.choices[0].message.role).toBe("assistant"); + expect(result.choices[0].message.content).toBe("Hello from Apple Intelligence"); + expect(result.choices[0].message.refusal).toBeNull(); + expect(result.id).toMatch(/^chatcmpl-/); + expect(result.usage).toBeNull(); + expect(result.system_fingerprint).toBeNull(); + client.close(); + }); + + it("disposes session after successful create", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + await client.chat.completions.create({ messages: basicMessages }); + + // Session dispose calls FMRelease on the session pointer + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + + it("disposes session even on error", async () => { + // Status 7 = RateLimitedError + simulateRespondError(7, "Rate limited"); + + const client = new Client(); + await expect(client.chat.completions.create({ messages: basicMessages })).rejects.toThrow(); + + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + + it("warns on unsupported model name", async () => { + simulateRespondSuccess("test"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.chat.completions.create({ + messages: basicMessages, + model: "gpt-4o", + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("gpt-4o")); + warnSpy.mockRestore(); + client.close(); + }); + }); + + describe("close", () => { + it("disposes the model", () => { + const client = new Client(); + client.close(); + // SystemLanguageModel.dispose calls FMRelease on model pointer + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-model-pointer"); + }); + + it("supports Symbol.dispose", () => { + const client = new Client(); + expect(typeof client[Symbol.dispose]).toBe("function"); + client[Symbol.dispose](); + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-model-pointer"); + }); + }); + + describe("error handling — non-streaming", () => { + it("returns finish_reason length for ExceededContextWindowSizeError", async () => { + // Status 1 = ExceededContextWindowSizeError + simulateRespondError(1, "Context window exceeded"); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + }); + + expect(result.choices[0].finish_reason).toBe("length"); + client.close(); + }); + + it("returns refusal for RefusalError", async () => { + // Status 9 = RefusalError + simulateRespondError(9, "Model refused"); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + }); + + expect(result.choices[0].message.content).toBeNull(); + expect(result.choices[0].message.refusal).toContain("refused"); + client.close(); + }); + + it("re-throws RateLimitedError with status 429", async () => { + simulateRespondError(7, "Rate limited"); + + const client = new Client(); + try { + await client.chat.completions.create({ messages: basicMessages }); + expect.unreachable("Should have thrown"); + } catch (err) { + expect((err as { status: number }).status).toBe(429); + } + client.close(); + }); + + it("returns finish_reason content_filter for GuardrailViolationError", async () => { + simulateRespondError(3, "Guardrail violation"); + + const client = new Client(); + const result = await client.chat.completions.create({ messages: basicMessages }); + expect(result.choices[0].finish_reason).toBe("content_filter"); + expect(result.choices[0].message.content).toBeNull(); + client.close(); + }); + + it("re-throws unhandled errors directly", async () => { + // Status 2 = AssetsUnavailableError — not specially handled by compat layer + simulateRespondError(2, "Assets unavailable"); + + const client = new Client(); + await expect(client.chat.completions.create({ messages: basicMessages })).rejects.toThrow( + "Assets unavailable", + ); + client.close(); + }); + }); + + describe("streaming", () => { + it("returns a Stream instance", async () => { + simulateStreamSuccess(["Hello", " world"]); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + expect(stream).toBeDefined(); + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + client.close(); + }); + + it("emits first chunk with role and empty content", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks[0].choices[0].delta.role).toBe("assistant"); + expect(chunks[0].choices[0].delta.content).toBe(""); + client.close(); + }); + + it("emits final chunk with finish_reason stop", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("stop"); + client.close(); + }); + }); + + describe("tools — non-streaming", () => { + it("returns tool_calls when model decides to call a tool", async () => { + simulateStructuredSuccess({ + type: "tool_call", + tool_call: { name: "get_weather", arguments: { city: "SF" } }, + }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + }); + + expect(result.choices[0].finish_reason).toBe("tool_calls"); + expect(result.choices[0].message.content).toBeNull(); + expect(result.choices[0].message.tool_calls).toHaveLength(1); + expect(result.choices[0].message.tool_calls![0].function.name).toBe("get_weather"); + expect(result.choices[0].message.tool_calls![0].function.arguments).toBe('{"city":"SF"}'); + expect(result.choices[0].message.tool_calls![0].id).toMatch(/^call_/); + client.close(); + }); + + it("returns empty string content when model text response has no content field", async () => { + simulateStructuredSuccess({ type: "text" }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + }); + + expect(result.choices[0].finish_reason).toBe("stop"); + expect(result.choices[0].message.content).toBe(""); + client.close(); + }); + + it("returns text when model responds with text despite tools", async () => { + simulateStructuredSuccess({ type: "text", content: "I can help with that" }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + }); + + expect(result.choices[0].finish_reason).toBe("stop"); + expect(result.choices[0].message.content).toBe("I can help with that"); + expect(result.choices[0].message.tool_calls).toBeUndefined(); + client.close(); + }); + + it("injects tool instructions when no system message exists", async () => { + simulateStructuredSuccess({ type: "text", content: "ok" }); + + const client = new Client(); + await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + }); + + // Verify FMTranscriptCreateFromJSONString was called with tool instructions in transcript + expect(mockFns.FMTranscriptCreateFromJSONString).toHaveBeenCalled(); + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instructionsEntry = parsed.transcript.entries.find( + (e: { role: string }) => e.role === "instructions", + ); + expect(instructionsEntry).toBeDefined(); + expect(instructionsEntry.contents[0].text).toContain("get_weather"); + client.close(); + }); + + it("appends tool instructions to existing system message", async () => { + simulateStructuredSuccess({ type: "text", content: "ok" }); + + const client = new Client(); + await client.chat.completions.create({ + messages: [ + { role: "system", content: "Be helpful." }, + { role: "user", content: "Hello" }, + ], + tools: sampleTools, + }); + + expect(mockFns.FMTranscriptCreateFromJSONString).toHaveBeenCalled(); + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instructionsEntry = parsed.transcript.entries.find( + (e: { role: string }) => e.role === "instructions", + ); + expect(instructionsEntry.contents[0].text).toContain("Be helpful."); + expect(instructionsEntry.contents[0].text).toContain("get_weather"); + client.close(); + }); + + it("uses plain text when last message is a tool result", async () => { + simulateRespondSuccess("The weather in SF is sunny."); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function" as const, + function: { name: "get_weather", arguments: '{"city":"SF"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "Sunny, 72°F" }, + ], + tools: sampleTools, + }); + + // Should use plain respond (not structured), so finish_reason is "stop" + expect(result.choices[0].finish_reason).toBe("stop"); + expect(result.choices[0].message.content).toBe("The weather in SF is sunny."); + expect(result.choices[0].message.tool_calls).toBeUndefined(); + client.close(); + }); + }); + + describe("json_schema response format — non-streaming", () => { + it("returns parsed JSON as content string", async () => { + simulateStructuredSuccess({ name: "John", age: 30 }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + }); + + expect(result.choices[0].finish_reason).toBe("stop"); + const parsed = JSON.parse(result.choices[0].message.content!); + expect(parsed.name).toBe("John"); + expect(parsed.age).toBe(30); + client.close(); + }); + + it("falls back to { type: 'object' } schema when json_schema.schema is omitted", async () => { + simulateStructuredSuccess({ answer: 42 }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + response_format: { + type: "json_schema", + json_schema: { name: "Bare" }, + } as never, + }); + + expect(result.choices[0].finish_reason).toBe("stop"); + expect(result.choices[0].message.content).toBeDefined(); + client.close(); + }); + + it("reorders JSON keys to match schema property order", async () => { + // Model returns keys in different order than schema defines + simulateStructuredSuccess({ age: 30, name: "John" }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + }); + + // Keys should be in schema order: name first, then age + const content = result.choices[0].message.content!; + expect(content).toBe('{"name":"John","age":30}'); + client.close(); + }); + + it("preserves extra keys not in schema during reordering", async () => { + simulateStructuredSuccess({ name: "John", extra: "data", age: 30 }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + }); + + const parsed = JSON.parse(result.choices[0].message.content!); + expect(Object.keys(parsed)).toEqual(["name", "age", "extra"]); + client.close(); + }); + + it("passes through invalid JSON unchanged during reordering", async () => { + // Make decodeAndFreeString return invalid JSON for this test + mockFns.FMLanguageModelSessionRespondWithSchemaFromJSON.mockImplementation( + (..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(0, "mock-content-pointer", null); + }, 0); + return "mock-task-pointer"; + }, + ); + mockFns.FMGeneratedContentGetJSONString.mockReturnValue("mock-json-pointer"); + decodeAndFreeStringMock.mockImplementation((pointer: unknown) => { + if (!pointer) return null; + return "not valid json {{{"; + }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + response_format: { + type: "json_schema", + json_schema: { + name: "Test", + schema: { + type: "object", + properties: { x: { type: "string" } }, + }, + }, + }, + }); + + expect(result.choices[0].message.content).toBe("not valid json {{{"); + client.close(); + }); + + it("skips missing schema properties when reordering", async () => { + // Object is missing the "age" property defined in schema + simulateStructuredSuccess({ name: "John" }); + + const client = new Client(); + const result = await client.chat.completions.create({ + messages: basicMessages, + response_format: { + type: "json_schema", + json_schema: { + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + }); + + const parsed = JSON.parse(result.choices[0].message.content!); + expect(Object.keys(parsed)).toEqual(["name"]); + client.close(); + }); + }); + + describe("json_object response format", () => { + it("appends JSON instruction to prompt", async () => { + simulateRespondSuccess('{"result": 42}'); + + const client = new Client(); + await client.chat.completions.create({ + messages: basicMessages, + response_format: { type: "json_object" }, + }); + + // Check that the prompt passed to FMLanguageModelSessionRespond includes JSON instruction + const respondArgs = mockFns.FMLanguageModelSessionRespond.mock.calls[0]; + const prompt = respondArgs[1] as string; + expect(prompt).toContain("Respond with valid JSON only"); + client.close(); + }); + }); + + describe("streaming — tools", () => { + it("buffers tool call and emits as chunks", async () => { + simulateStructuredSuccess({ + type: "tool_call", + tool_call: { name: "get_weather", arguments: { city: "NYC" } }, + }); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + // First chunk: role announcement + expect(chunks[0].choices[0].delta.role).toBe("assistant"); + // Tool call chunk + const toolChunk = chunks.find((c) => c.choices[0].delta.tool_calls); + expect(toolChunk).toBeDefined(); + expect(toolChunk!.choices[0].delta.tool_calls![0].function!.name).toBe("get_weather"); + // Final chunk + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("tool_calls"); + client.close(); + }); + + it("buffers text response with missing content and emits empty string", async () => { + simulateStructuredSuccess({ type: "text" }); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const contentChunk = chunks.find((c) => c.choices[0].delta.content === ""); + expect(contentChunk).toBeDefined(); + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("stop"); + client.close(); + }); + + it("buffers text response with tools and emits as chunks", async () => { + simulateStructuredSuccess({ type: "text", content: "No tool needed" }); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + tools: sampleTools, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const contentChunk = chunks.find((c) => c.choices[0].delta.content === "No tool needed"); + expect(contentChunk).toBeDefined(); + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("stop"); + client.close(); + }); + }); + + describe("streaming — error handling", () => { + it("emits finish_reason length for ExceededContextWindowSizeError", async () => { + simulateStreamError(1, "Context window exceeded"); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("length"); + client.close(); + }); + + it("emits refusal chunk for RefusalError", async () => { + simulateStreamError(9, "Model refused"); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + const chunks: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const refusalChunk = chunks.find((c) => c.choices[0].delta.refusal); + expect(refusalChunk).toBeDefined(); + expect(refusalChunk!.choices[0].delta.refusal).toContain("refused"); + client.close(); + }); + + it("throws CompatError with status 429 for RateLimitedError", async () => { + simulateStreamError(7, "Rate limited"); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + try { + for await (const chunk of stream) { + expect(chunk).toBeDefined(); + } + expect.unreachable("Should have thrown"); + } catch (err) { + expect((err as { status: number }).status).toBe(429); + } + client.close(); + }); + + it("yields finish_reason content_filter for GuardrailViolationError during streaming", async () => { + simulateStreamError(3, "Guardrail violation"); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("content_filter"); + client.close(); + }); + + it("re-throws unhandled errors directly during streaming", async () => { + simulateStreamError(2, "Assets unavailable"); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + await expect(async () => { + for await (const chunk of stream) { + expect(chunk).toBeDefined(); + } + }).rejects.toThrow("Assets unavailable"); + client.close(); + }); + + it("stream.close() disposes the session", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.chat.completions.create({ + messages: basicMessages, + stream: true, + }); + + mockFns.FMRelease.mockClear(); + stream.close(); + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + }); +}); diff --git a/tests/unit/compat/params.test.ts b/tests/unit/compat/params.test.ts new file mode 100644 index 0000000..ef7deb5 --- /dev/null +++ b/tests/unit/compat/params.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mapParams } from "../../../src/compat/params.js"; +import { SamplingMode } from "../../../src/options.js"; + +describe("mapParams", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns empty options when no params provided", () => { + const result = mapParams({}); + expect(result).toEqual({}); + }); + + it("maps temperature to GenerationOptions.temperature", () => { + const result = mapParams({ temperature: 0.7 }); + expect(result.temperature).toBe(0.7); + }); + + it("maps max_tokens to maximumResponseTokens", () => { + const result = mapParams({ max_tokens: 512 }); + expect(result.maximumResponseTokens).toBe(512); + }); + + it("maps max_completion_tokens to maximumResponseTokens", () => { + const result = mapParams({ max_completion_tokens: 256 }); + expect(result.maximumResponseTokens).toBe(256); + }); + + it("prefers max_completion_tokens over max_tokens when both present", () => { + const result = mapParams({ max_tokens: 512, max_completion_tokens: 256 }); + expect(result.maximumResponseTokens).toBe(256); + }); + + it("maps top_p to SamplingMode.random with probabilityThreshold", () => { + const result = mapParams({ top_p: 0.9 }); + expect(result.sampling).toEqual(SamplingMode.random({ probabilityThreshold: 0.9 })); + }); + + it("maps seed to SamplingMode.random with seed", () => { + const result = mapParams({ seed: 42 }); + expect(result.sampling).toEqual(SamplingMode.random({ seed: 42 })); + }); + + it("combines top_p and seed into a single SamplingMode.random", () => { + const result = mapParams({ top_p: 0.8, seed: 7 }); + expect(result.sampling).toEqual(SamplingMode.random({ probabilityThreshold: 0.8, seed: 7 })); + }); + + it("sets temperature independently from sampling mode", () => { + const result = mapParams({ temperature: 0.5, top_p: 0.9 }); + expect(result.temperature).toBe(0.5); + expect(result.sampling).toEqual(SamplingMode.random({ probabilityThreshold: 0.9 })); + }); + + it("warns for each unsupported param that is non-null", () => { + mapParams({ n: 2, stop: "STOP", logprobs: true }); + expect(console.warn).toHaveBeenCalledTimes(3); + }); + + it("warns when model is not SystemLanguageModel", () => { + mapParams({ model: "gpt-4o" }); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("gpt-4o")); + }); + + it("does not warn when model is SystemLanguageModel", () => { + mapParams({ model: "SystemLanguageModel" }); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("does not warn when model is omitted", () => { + mapParams({}); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("treats null values as not provided", () => { + const result = mapParams({ + temperature: null, + max_tokens: null, + max_completion_tokens: null, + top_p: null, + seed: null, + }); + expect(result).toEqual({}); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("does not warn for null unsupported params", () => { + mapParams({ n: null, stop: null }); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("warns when both max_tokens and max_completion_tokens are set", () => { + const result = mapParams({ max_tokens: 512, max_completion_tokens: 256 }); + expect(result.maximumResponseTokens).toBe(256); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Both "max_tokens" and "max_completion_tokens"'), + ); + }); + + it("warns when tool_choice is set to a non-auto value", () => { + mapParams({ tool_choice: "required" }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('"tool_choice" value "required"'), + ); + }); + + it("warns when tool_choice is set to an object", () => { + mapParams({ tool_choice: { type: "function", function: { name: "test" } } }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('"tool_choice" value "object"'), + ); + }); + + it("does not warn when tool_choice is auto", () => { + mapParams({ tool_choice: "auto" }); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("does not warn when tool_choice is not set", () => { + mapParams({}); + expect(console.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/compat/responses-stream.test.ts b/tests/unit/compat/responses-stream.test.ts new file mode 100644 index 0000000..754a09c --- /dev/null +++ b/tests/unit/compat/responses-stream.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi } from "vitest"; + +type RegistryCallback = (cleanup: () => void) => void; + +const { getRegistryCallback } = vi.hoisted(() => { + let captured: RegistryCallback | null = null; + + globalThis.FinalizationRegistry = class MockFinalizationRegistry { + constructor(callback: RegistryCallback) { + captured = callback; + } + register() {} + unregister() {} + } as unknown as typeof FinalizationRegistry; + + return { + getRegistryCallback: () => captured, + }; +}); + +import { ResponseStream } from "../../../src/compat/responses-stream.js"; +import type { ResponseStreamEvent } from "../../../src/compat/responses-types.js"; + +function makeEvent(type: string, seq: number): ResponseStreamEvent { + return { + type: "response.output_text.delta", + delta: "test", + item_id: "msg_1", + output_index: 0, + content_index: 0, + sequence_number: seq, + } as ResponseStreamEvent; +} + +async function* makeSource(events: ResponseStreamEvent[]): AsyncIterable { + for (const event of events) { + yield event; + } +} + +describe("ResponseStream", () => { + it("is async iterable", async () => { + const events = [makeEvent("delta", 0)]; + const stream = new ResponseStream(makeSource(events)); + + const results: ResponseStreamEvent[] = []; + for await (const event of stream) { + results.push(event); + } + + expect(results).toHaveLength(1); + }); + + it("yields multiple events in order", async () => { + const events = [makeEvent("delta", 0), makeEvent("delta", 1), makeEvent("delta", 2)]; + const stream = new ResponseStream(makeSource(events)); + + const results: ResponseStreamEvent[] = []; + for await (const event of stream) { + results.push(event); + } + + expect(results).toHaveLength(3); + expect(results[0].sequence_number).toBe(0); + expect(results[2].sequence_number).toBe(2); + }); + + it("close() invokes the cleanup callback", () => { + const cleanup = vi.fn(); + const stream = new ResponseStream(makeSource([]), cleanup); + stream.close(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("close() is idempotent", () => { + const cleanup = vi.fn(); + const stream = new ResponseStream(makeSource([]), cleanup); + stream.close(); + stream.close(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("break during iteration calls cleanup via return()", async () => { + const cleanup = vi.fn(); + const events = [makeEvent("delta", 0), makeEvent("delta", 1), makeEvent("delta", 2)]; + const stream = new ResponseStream(makeSource(events), cleanup); + + for await (const _event of stream) { + break; + } + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("error during iteration calls cleanup", async () => { + const cleanup = vi.fn(); + async function* errorSource(): AsyncIterable { + yield makeEvent("delta", 0); + throw new Error("boom"); + } + const stream = new ResponseStream(errorSource(), cleanup); + + await expect(async () => { + for await (const _event of stream) { + // consume + } + }).rejects.toThrow("boom"); + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("toReadableStream() returns a ReadableStream", () => { + const stream = new ResponseStream(makeSource([])); + const readable = stream.toReadableStream(); + expect(readable).toBeInstanceOf(ReadableStream); + }); + + it("ReadableStream provides events correctly", async () => { + const events = [makeEvent("delta", 0), makeEvent("delta", 1)]; + const stream = new ResponseStream(makeSource(events)); + const readable = stream.toReadableStream(); + + const reader = readable.getReader(); + const results: ResponseStreamEvent[] = []; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + results.push(value); + } + + expect(results).toHaveLength(2); + }); + + it("break works when inner iterator has no return() method", async () => { + const cleanup = vi.fn(); + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + let i = 0; + const events = [makeEvent("delta", 0), makeEvent("delta", 1)]; + return { + async next() { + if (i < events.length) { + return { done: false as const, value: events[i++] }; + } + return { done: true as const, value: undefined as unknown as ResponseStreamEvent }; + }, + // Intentionally no return() method + }; + }, + }; + const stream = new ResponseStream(source, cleanup); + + for await (const _event of stream) { + break; + } + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("toReadableStream() propagates errors from the iterator", async () => { + async function* errorSource(): AsyncIterable { + yield makeEvent("delta", 0); + throw new Error("stream error"); + } + const stream = new ResponseStream(errorSource()); + const readable = stream.toReadableStream(); + const reader = readable.getReader(); + + // First read succeeds + const first = await reader.read(); + expect(first.done).toBe(false); + + // Second read should reject with the error + await expect(reader.read()).rejects.toThrow("stream error"); + }); + + it("FinalizationRegistry callback invokes the cleanup function", () => { + const registryCallback = getRegistryCallback(); + expect(registryCallback).toBeTypeOf("function"); + const cleanup = vi.fn(); + registryCallback!(cleanup); + expect(cleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/compat/responses.test.ts b/tests/unit/compat/responses.test.ts new file mode 100644 index 0000000..75d5d64 --- /dev/null +++ b/tests/unit/compat/responses.test.ts @@ -0,0 +1,1690 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockFunctions } from "../helpers/mock-bindings.js"; + +// --------------------------------------------------------------------------- +// Hoisted mocks — must run before any import +// --------------------------------------------------------------------------- + +const { decodeAndFreeStringMock } = vi.hoisted(() => { + globalThis.FinalizationRegistry = class MockFinalizationRegistry { + constructor(_callback: unknown) {} + register() {} + unregister() {} + } as unknown as typeof FinalizationRegistry; + + return { + decodeAndFreeStringMock: vi.fn((_pointer: unknown): string | null => { + if (!_pointer) return null; + return '{"key":"value"}'; + }), + }; +}); + +const mockFns = createMockFunctions(); + +let lastRegisteredCallback: ((...args: unknown[]) => void) | null = null; + +vi.mock("koffi", () => ({ + default: { + register: vi.fn((cb: (...args: unknown[]) => void, _proto: unknown) => { + lastRegisteredCallback = cb; + return "mock-cb-pointer"; + }), + unregister: vi.fn(), + as: vi.fn((_arr: unknown[], _type: string) => "mock-arr-pointer"), + pointer: vi.fn((_proto: unknown) => "mock-proto-pointer"), + }, +})); + +vi.mock("../../../src/bindings.js", () => ({ + getFunctions: () => mockFns, + decodeAndFreeString: decodeAndFreeStringMock, + unregisterCallback: vi.fn(), + ResponseCallbackProto: "ResponseCallbackProto", + StructuredResponseCallbackProto: "StructuredResponseCallbackProto", +})); + +vi.mock("../../../src/tool.js", () => ({ + Tool: class MockTool { + _nativeTool = "mock-tool-pointer"; + _register() {} + }, +})); + +import Client from "../../../src/compat/index.js"; +import type { + Response, + ResponseStreamEvent, + ResponseOutputMessage, + ResponseOutputFunctionToolCall, +} from "../../../src/compat/responses-types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function simulateRespondSuccess(text: string) { + mockFns.FMLanguageModelSessionRespond.mockImplementation((..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(0, text, text.length, null); + }, 0); + return "mock-task-pointer"; + }); +} + +function simulateRespondError(status: number, msg: string) { + mockFns.FMLanguageModelSessionRespond.mockImplementation((..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(status, msg, msg.length, null); + }, 0); + return "mock-task-pointer"; + }); +} + +function simulateStreamSuccess(chunks: string[]) { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + let cumulative = ""; + let i = 0; + function next() { + if (i < chunks.length) { + cumulative += chunks[i]; + i++; + setTimeout(() => { + lastRegisteredCallback?.(0, cumulative, cumulative.length, null); + next(); + }, 0); + } else { + setTimeout(() => { + lastRegisteredCallback?.(0, null, 0, null); + }, 0); + } + } + next(); + }, + ); +} + +function simulateStructuredSuccess(jsonObj: Record) { + const jsonStr = JSON.stringify(jsonObj); + mockFns.FMLanguageModelSessionRespondWithSchemaFromJSON.mockImplementation( + (..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(0, "mock-content-pointer", null); + }, 0); + return "mock-task-pointer"; + }, + ); + mockFns.FMGeneratedContentGetJSONString.mockReturnValue("mock-json-pointer"); + decodeAndFreeStringMock.mockImplementation((pointer: unknown) => { + if (!pointer) return null; + return jsonStr; + }); +} + +function simulateStreamError(status: number, msg: string) { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + setTimeout(() => { + lastRegisteredCallback?.(status, msg, msg.length, null); + }, 0); + }, + ); +} + +const sampleFunctionTools = [ + { + type: "function" as const, + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + lastRegisteredCallback = null; + decodeAndFreeStringMock.mockImplementation((_pointer: unknown): string | null => { + if (!_pointer) return null; + return '{"key":"value"}'; + }); +}); + +describe("Responses API compat layer", () => { + describe("structure", () => { + it("has responses.create method", () => { + const client = new Client(); + expect(client.responses).toBeDefined(); + expect(typeof client.responses.create).toBe("function"); + client.close(); + }); + }); + + describe("create — string input", () => { + it("returns correct Response shape for simple string input", async () => { + simulateRespondSuccess("Hello from Apple Intelligence"); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Hello", + })) as Response; + + expect(result.object).toBe("response"); + expect(result.model).toBe("SystemLanguageModel"); + expect(result.status).toBe("completed"); + expect(result.id).toMatch(/^resp_/); + expect(result.output).toHaveLength(1); + expect(result.output[0].type).toBe("message"); + const msg = result.output[0] as ResponseOutputMessage; + expect(msg.role).toBe("assistant"); + expect(msg.status).toBe("completed"); + expect(msg.content).toHaveLength(1); + expect(msg.content[0].type).toBe("output_text"); + expect((msg.content[0] as { type: "output_text"; text: string }).text).toBe( + "Hello from Apple Intelligence", + ); + expect(result.output_text).toBe("Hello from Apple Intelligence"); + expect(result.error).toBeNull(); + expect(result.incomplete_details).toBeNull(); + expect(result.usage).toBeNull(); + client.close(); + }); + }); + + describe("create — array input with messages", () => { + it("returns response for array input with user message", async () => { + simulateRespondSuccess("Hi there"); + + const client = new Client(); + const result = (await client.responses.create({ + input: [{ role: "user", content: "Hello" }], + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("Hi there"); + client.close(); + }); + + it("throws when input array has no user message", async () => { + const client = new Client(); + await expect( + client.responses.create({ + input: [{ role: "assistant", content: "I am assistant" }], + }), + ).rejects.toThrow("at least one user message"); + client.close(); + }); + + it("handles input_text content parts in array messages", async () => { + simulateRespondSuccess("Got it"); + + const client = new Client(); + const result = (await client.responses.create({ + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Hello " }, + { type: "input_text", text: "world" }, + ], + }, + ], + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("Got it"); + client.close(); + }); + + it("warns on unsupported content part types", async () => { + simulateRespondSuccess("ok"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.responses.create({ + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "hello" }, + { type: "input_image", image_url: "http://example.com/img.png" } as never, + ], + }, + ], + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("input_image")); + warnSpy.mockRestore(); + client.close(); + }); + }); + + describe("instructions parameter", () => { + it("injects instructions into transcript", async () => { + simulateRespondSuccess("Sure!"); + + const client = new Client(); + await client.responses.create({ + input: "Hello", + instructions: "Be concise.", + }); + + expect(mockFns.FMTranscriptCreateFromJSONString).toHaveBeenCalled(); + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instrEntry = parsed.transcript.entries.find( + (e: { role: string }) => e.role === "instructions", + ); + expect(instrEntry).toBeDefined(); + expect(instrEntry.contents[0].text).toContain("Be concise."); + client.close(); + }); + + it("reflects instructions in response object", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Hi", + instructions: "Be helpful.", + })) as Response; + + expect(result.instructions).toBe("Be helpful."); + client.close(); + }); + }); + + describe("multi-turn conversations", () => { + it("handles user/assistant turns in array input", async () => { + simulateRespondSuccess("The capital of France is Paris."); + + const client = new Client(); + const result = (await client.responses.create({ + input: [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello! How can I help?" }, + { role: "user", content: "What is the capital of France?" }, + ], + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("The capital of France is Paris."); + client.close(); + }); + + it("handles system/developer messages in array input", async () => { + simulateRespondSuccess("Done"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hello" }, + ], + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instrEntry = parsed.transcript.entries.find( + (e: { role: string }) => e.role === "instructions", + ); + expect(instrEntry).toBeDefined(); + expect(instrEntry.contents[0].text).toContain("You are helpful."); + client.close(); + }); + }); + + describe("parameter mapping", () => { + it("passes temperature through to options", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + const result = (await client.responses.create({ + input: "test", + temperature: 0.7, + })) as Response; + + expect(result.temperature).toBe(0.7); + client.close(); + }); + + it("passes max_output_tokens through", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + const result = (await client.responses.create({ + input: "test", + max_output_tokens: 100, + })) as Response; + + expect(result.max_output_tokens).toBe(100); + client.close(); + }); + + it("passes top_p and seed through", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + const result = (await client.responses.create({ + input: "test", + top_p: 0.9, + seed: 42, + })) as Response; + + expect(result.top_p).toBe(0.9); + client.close(); + }); + }); + + describe("unsupported param warnings", () => { + it("warns on unsupported model name", async () => { + simulateRespondSuccess("test"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.responses.create({ + input: "test", + model: "gpt-4o", + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("gpt-4o")); + warnSpy.mockRestore(); + client.close(); + }); + + it("warns on unsupported parameters", async () => { + simulateRespondSuccess("test"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.responses.create({ + input: "test", + store: true, + user: "user-123", + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"store"')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"user"')); + warnSpy.mockRestore(); + client.close(); + }); + + it("warns when tool_choice is set to a non-auto value", async () => { + simulateRespondSuccess("test"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.responses.create({ + input: "test", + tool_choice: "required", + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"tool_choice" value "required"'), + ); + warnSpy.mockRestore(); + client.close(); + }); + + it("warns when tool_choice is set to an object value", async () => { + simulateRespondSuccess("test"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.responses.create({ + input: "test", + tool_choice: { type: "function", name: "my_func" }, + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"tool_choice" value "object"')); + warnSpy.mockRestore(); + client.close(); + }); + + it("does not warn when tool_choice is auto", async () => { + simulateRespondSuccess("test"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client(); + await client.responses.create({ + input: "test", + tool_choice: "auto", + }); + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("tool_choice")); + warnSpy.mockRestore(); + client.close(); + }); + }); + + describe("response object structure", () => { + it("includes all expected top-level fields", async () => { + simulateRespondSuccess("Hello"); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Hi", + instructions: "Be nice", + temperature: 0.5, + top_p: 0.9, + max_output_tokens: 200, + })) as Response; + + expect(result.id).toMatch(/^resp_/); + expect(result.object).toBe("response"); + expect(typeof result.created_at).toBe("number"); + expect(result.model).toBe("SystemLanguageModel"); + expect(result.status).toBe("completed"); + expect(result.error).toBeNull(); + expect(result.incomplete_details).toBeNull(); + expect(result.instructions).toBe("Be nice"); + expect(result.temperature).toBe(0.5); + expect(result.top_p).toBe(0.9); + expect(result.max_output_tokens).toBe(200); + expect(result.tool_choice).toBe("auto"); + expect(result.tools).toEqual([]); + expect(result.parallel_tool_calls).toBe(false); + expect(result.text).toEqual({ format: { type: "text" } }); + expect(result.truncation).toBeNull(); + expect(result.usage).toBeNull(); + expect(result.metadata).toBeNull(); + client.close(); + }); + + it("disposes session after successful create", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + await client.responses.create({ input: "test" }); + + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + + it("disposes session even on error", async () => { + simulateRespondError(7, "Rate limited"); + + const client = new Client(); + await expect(client.responses.create({ input: "test" })).rejects.toThrow(); + + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + }); + + describe("structured output via text.format with json_schema", () => { + it("returns parsed JSON as output_text", async () => { + simulateStructuredSuccess({ name: "Alice", age: 25 }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Generate a person", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + })) as Response; + + expect(result.status).toBe("completed"); + const parsed = JSON.parse(result.output_text); + expect(parsed.name).toBe("Alice"); + expect(parsed.age).toBe(25); + client.close(); + }); + + it("reorders JSON keys to match schema property order", async () => { + simulateStructuredSuccess({ age: 30, name: "Bob" }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Generate", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + })) as Response; + + expect(result.output_text).toBe('{"name":"Bob","age":30}'); + client.close(); + }); + + it("falls back to { type: 'object' } schema when schema is omitted", async () => { + simulateStructuredSuccess({ answer: 42 }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "test", + text: { + format: { + type: "json_schema", + name: "Bare", + } as never, + }, + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBeDefined(); + client.close(); + }); + }); + + describe("tool calling (function tools)", () => { + it("returns function_call output item when model calls a tool", async () => { + simulateStructuredSuccess({ + type: "tool_call", + tool_call: { name: "get_weather", arguments: { city: "SF" } }, + }); + + const client = new Client(); + const result = (await client.responses.create({ + input: [{ role: "user", content: "What is the weather in SF?" }], + tools: sampleFunctionTools, + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output).toHaveLength(1); + const fc = result.output[0] as ResponseOutputFunctionToolCall; + expect(fc.type).toBe("function_call"); + expect(fc.name).toBe("get_weather"); + expect(fc.arguments).toBe('{"city":"SF"}'); + expect(fc.call_id).toMatch(/^call_/); + expect(fc.id).toMatch(/^fc_/); + expect(fc.status).toBe("completed"); + // output_text should be empty for tool calls + expect(result.output_text).toBe(""); + client.close(); + }); + + it("returns text when model responds with text despite tools", async () => { + simulateStructuredSuccess({ type: "text", content: "I can help with that" }); + + const client = new Client(); + const result = (await client.responses.create({ + input: [{ role: "user", content: "Hello" }], + tools: sampleFunctionTools, + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("I can help with that"); + const msg = result.output[0] as ResponseOutputMessage; + expect(msg.type).toBe("message"); + client.close(); + }); + + it("returns empty string content when model text response has no content field", async () => { + simulateStructuredSuccess({ type: "text" }); + + const client = new Client(); + const result = (await client.responses.create({ + input: [{ role: "user", content: "Hello" }], + tools: sampleFunctionTools, + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe(""); + client.close(); + }); + + it("injects tool instructions when no system message exists", async () => { + simulateStructuredSuccess({ type: "text", content: "ok" }); + + const client = new Client(); + await client.responses.create({ + input: [{ role: "user", content: "Hello" }], + tools: sampleFunctionTools, + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instrEntry = parsed.transcript.entries.find( + (e: { role: string }) => e.role === "instructions", + ); + expect(instrEntry).toBeDefined(); + expect(instrEntry.contents[0].text).toContain("get_weather"); + client.close(); + }); + + it("appends tool instructions to existing instructions", async () => { + simulateStructuredSuccess({ type: "text", content: "ok" }); + + const client = new Client(); + await client.responses.create({ + input: [{ role: "user", content: "Hello" }], + instructions: "Be helpful.", + tools: sampleFunctionTools, + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instrEntry = parsed.transcript.entries.find( + (e: { role: string }) => e.role === "instructions", + ); + expect(instrEntry.contents[0].text).toContain("Be helpful."); + expect(instrEntry.contents[0].text).toContain("get_weather"); + client.close(); + }); + }); + + describe("tool results (function_call_output in input)", () => { + it("uses plain text respond when last input is function_call_output", async () => { + simulateRespondSuccess("The weather in SF is sunny."); + + const client = new Client(); + const result = (await client.responses.create({ + input: [ + { role: "user", content: "What's the weather?" }, + { + type: "function_call", + name: "get_weather", + arguments: '{"city":"SF"}', + call_id: "call_1", + }, + { + type: "function_call_output", + call_id: "call_1", + output: "Sunny, 72F", + }, + ], + tools: sampleFunctionTools, + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("The weather in SF is sunny."); + // Should NOT have any function_call items since tool result flow uses plain respond + const msg = result.output[0] as ResponseOutputMessage; + expect(msg.type).toBe("message"); + client.close(); + }); + + it("includes tool result text in the synthetic user message", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "user", content: "Get weather" }, + { + type: "function_call", + name: "get_weather", + arguments: '{"city":"NYC"}', + call_id: "call_2", + }, + { + type: "function_call_output", + call_id: "call_2", + output: "Rainy, 55F", + }, + ], + tools: sampleFunctionTools, + }); + + // The prompt should include the tool result text + const respondArgs = mockFns.FMLanguageModelSessionRespond.mock.calls[0]; + const prompt = respondArgs[1] as string; + expect(prompt).toContain("Tool result"); + expect(prompt).toContain("Rainy, 55F"); + client.close(); + }); + }); + + describe("error mapping — non-streaming", () => { + it("returns incomplete status with error for ExceededContextWindowSizeError", async () => { + simulateRespondError(1, "Context window exceeded"); + + const client = new Client(); + const result = (await client.responses.create({ input: "test" })) as Response; + + expect(result.status).toBe("incomplete"); + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe("max_output_tokens"); + expect(result.error!.message).toContain("Context window exceeded"); + expect(result.incomplete_details).toEqual({ reason: "max_output_tokens" }); + client.close(); + }); + + it("returns refusal content for RefusalError", async () => { + simulateRespondError(9, "Model refused"); + + const client = new Client(); + const result = (await client.responses.create({ input: "test" })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output).toHaveLength(1); + const msg = result.output[0] as ResponseOutputMessage; + expect(msg.content[0].type).toBe("refusal"); + expect((msg.content[0] as { type: "refusal"; refusal: string }).refusal).toContain("refused"); + client.close(); + }); + + it("re-throws RateLimitedError with status 429", async () => { + simulateRespondError(7, "Rate limited"); + + const client = new Client(); + try { + await client.responses.create({ input: "test" }); + expect.unreachable("Should have thrown"); + } catch (err) { + expect((err as { status: number }).status).toBe(429); + } + client.close(); + }); + + it("returns failed with content_filter error for GuardrailViolationError", async () => { + simulateRespondError(3, "Guardrail violation"); + + const client = new Client(); + const result = (await client.responses.create({ input: "test" })) as Response; + + expect(result.status).toBe("failed"); + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe("content_filter"); + expect(result.error!.message).toContain("Guardrail violation"); + expect(result.incomplete_details).toEqual({ reason: "content_filter" }); + expect(result.output).toEqual([]); + client.close(); + }); + + it("re-throws unhandled errors directly", async () => { + simulateRespondError(2, "Assets unavailable"); + + const client = new Client(); + await expect(client.responses.create({ input: "test" })).rejects.toThrow( + "Assets unavailable", + ); + client.close(); + }); + }); + + describe("streaming — text deltas and event types", () => { + it("returns a ResponseStream instance", async () => { + simulateStreamSuccess(["Hello", " world"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + expect(stream).toBeDefined(); + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + expect(events.length).toBeGreaterThan(0); + client.close(); + }); + + it("emits response.created and response.in_progress first", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + expect(events[0].type).toBe("response.created"); + expect(events[1].type).toBe("response.in_progress"); + client.close(); + }); + + it("emits text delta events during streaming", async () => { + simulateStreamSuccess(["Hello", " world"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hi", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const deltas = events.filter((e) => e.type === "response.output_text.delta"); + expect(deltas.length).toBeGreaterThan(0); + + const textDone = events.find((e) => e.type === "response.output_text.done"); + expect(textDone).toBeDefined(); + client.close(); + }); + + it("emits response.completed as final event", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const last = events[events.length - 1]; + expect(last.type).toBe("response.completed"); + client.close(); + }); + + it("emits sequence_numbers in order", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + for (let i = 0; i < events.length; i++) { + expect(events[i].sequence_number).toBe(i); + } + client.close(); + }); + + it("emits output_item.added, content_part.added, and output_item.done", async () => { + simulateStreamSuccess(["Test"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hi", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const types = events.map((e) => e.type); + expect(types).toContain("response.output_item.added"); + expect(types).toContain("response.content_part.added"); + expect(types).toContain("response.content_part.done"); + expect(types).toContain("response.output_item.done"); + client.close(); + }); + }); + + describe("streaming — tools", () => { + it("emits function_call events when model calls a tool", async () => { + simulateStructuredSuccess({ + type: "tool_call", + tool_call: { name: "get_weather", arguments: { city: "NYC" } }, + }); + + const client = new Client(); + const stream = await client.responses.create({ + input: [{ role: "user", content: "Weather?" }], + tools: sampleFunctionTools, + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const types = events.map((e) => e.type); + expect(types).toContain("response.output_item.added"); + expect(types).toContain("response.function_call_arguments.delta"); + expect(types).toContain("response.function_call_arguments.done"); + expect(types).toContain("response.output_item.done"); + expect(types).toContain("response.completed"); + + const argsDone = events.find((e) => e.type === "response.function_call_arguments.done"); + expect(argsDone).toBeDefined(); + if (argsDone?.type === "response.function_call_arguments.done") { + expect(argsDone.name).toBe("get_weather"); + expect(argsDone.arguments).toBe('{"city":"NYC"}'); + } + client.close(); + }); + + it("emits text events when model responds with text despite tools", async () => { + simulateStructuredSuccess({ type: "text", content: "No tool needed" }); + + const client = new Client(); + const stream = await client.responses.create({ + input: [{ role: "user", content: "Hello" }], + tools: sampleFunctionTools, + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const textDelta = events.find( + (e) => + e.type === "response.output_text.delta" && "delta" in e && e.delta === "No tool needed", + ); + expect(textDelta).toBeDefined(); + + const last = events[events.length - 1]; + expect(last.type).toBe("response.completed"); + client.close(); + }); + }); + + describe("streaming — structured output", () => { + it("buffers structured output and emits text events", async () => { + simulateStructuredSuccess({ name: "Alice", age: 25 }); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Generate", + stream: true, + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const types = events.map((e) => e.type); + expect(types).toContain("response.output_text.delta"); + expect(types).toContain("response.output_text.done"); + expect(types).toContain("response.completed"); + client.close(); + }); + }); + + describe("streaming — error handling", () => { + it("emits response.incomplete with error for ExceededContextWindowSizeError", async () => { + simulateStreamError(1, "Context window exceeded"); + + const client = new Client(); + const stream = await client.responses.create({ + input: "test", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const last = events[events.length - 1]; + expect(last.type).toBe("response.incomplete"); + if (last.type === "response.incomplete") { + expect(last.response.status).toBe("incomplete"); + expect(last.response.error).toBeDefined(); + expect(last.response.error!.code).toBe("max_output_tokens"); + expect(last.response.error!.message).toContain("Context window exceeded"); + expect(last.response.incomplete_details).toEqual({ reason: "max_output_tokens" }); + } + client.close(); + }); + + it("emits refusal via response.completed for RefusalError", async () => { + simulateStreamError(9, "Model refused"); + + const client = new Client(); + const stream = await client.responses.create({ + input: "test", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const last = events[events.length - 1]; + expect(last.type).toBe("response.completed"); + if (last.type === "response.completed") { + const msg = last.response.output[0] as ResponseOutputMessage; + expect(msg.content[0].type).toBe("refusal"); + } + client.close(); + }); + + it("throws CompatError with status 429 for RateLimitedError", async () => { + simulateStreamError(7, "Rate limited"); + + const client = new Client(); + const stream = await client.responses.create({ + input: "test", + stream: true, + }); + + try { + for await (const _event of stream) { + // consume + } + expect.unreachable("Should have thrown"); + } catch (err) { + expect((err as { status: number }).status).toBe(429); + } + client.close(); + }); + + it("emits response.failed with content_filter error for GuardrailViolationError", async () => { + simulateStreamError(3, "Guardrail violation"); + + const client = new Client(); + const stream = await client.responses.create({ + input: "test", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const last = events[events.length - 1]; + expect(last.type).toBe("response.failed"); + if (last.type === "response.failed") { + expect(last.response.status).toBe("failed"); + expect(last.response.error).toBeDefined(); + expect(last.response.error!.code).toBe("content_filter"); + expect(last.response.error!.message).toContain("Guardrail violation"); + expect(last.response.incomplete_details).toEqual({ reason: "content_filter" }); + } + client.close(); + }); + + it("re-throws unhandled errors during streaming", async () => { + simulateStreamError(2, "Assets unavailable"); + + const client = new Client(); + const stream = await client.responses.create({ + input: "test", + stream: true, + }); + + await expect(async () => { + for await (const _event of stream) { + // consume + } + }).rejects.toThrow("Assets unavailable"); + client.close(); + }); + }); + + describe("edge cases — coverage branches", () => { + it("handles multiple contiguous function_call_output items", async () => { + simulateRespondSuccess("Both results processed"); + + const client = new Client(); + const result = (await client.responses.create({ + input: [ + { role: "user", content: "Get data" }, + { + type: "function_call", + name: "tool_a", + arguments: "{}", + call_id: "call_a", + }, + { + type: "function_call", + name: "tool_b", + arguments: "{}", + call_id: "call_b", + }, + { + type: "function_call_output", + call_id: "call_a", + output: "Result A", + }, + { + type: "function_call_output", + call_id: "call_b", + output: "Result B", + }, + ], + })) as Response; + + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("Both results processed"); + client.close(); + }); + + it("handles duplicate system/developer messages (second becomes user entry)", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "system", content: "First system" }, + { role: "developer", content: "Second system" }, + { role: "user", content: "Hello" }, + ], + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const instrEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "instructions", + ); + expect(instrEntries).toHaveLength(1); + expect(instrEntries[0].contents[0].text).toContain("First system"); + // Second system/developer should be a user entry with [System] prefix + const userEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "user", + ); + const systemUser = userEntries.find((e: { contents: { text: string }[] }) => + e.contents[0].text.includes("[System]"), + ); + expect(systemUser).toBeDefined(); + expect(systemUser.contents[0].text).toContain("Second system"); + client.close(); + }); + + it("handles function_call_output with unresolvable call_id", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "user", content: "Do something" }, + { + type: "function_call_output", + call_id: "nonexistent_id", + output: "Some result", + }, + ], + }); + + const respondArgs = mockFns.FMLanguageModelSessionRespond.mock.calls[0]; + const prompt = respondArgs[1] as string; + expect(prompt).toContain("[Tool result]:"); + expect(prompt).not.toContain("[Tool result for "); + client.close(); + }); + + it("handles instructions + system message in array input (instructions param takes priority)", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "system", content: "System instruction" }, + { role: "user", content: "Hello" }, + ], + instructions: "Top-level instruction", + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + // instructions param creates the instructions entry + const instrEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "instructions", + ); + expect(instrEntries).toHaveLength(1); + expect(instrEntries[0].contents[0].text).toContain("Top-level instruction"); + // system message should become a [System] user entry + const userEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "user", + ); + const systemUser = userEntries.find((e: { contents: { text: string }[] }) => + e.contents[0].text.includes("[System]"), + ); + expect(systemUser).toBeDefined(); + client.close(); + }); + + it("reorderJson handles invalid JSON gracefully", async () => { + // Return invalid JSON from the structured response to trigger catch branch + mockFns.FMLanguageModelSessionRespondWithSchemaFromJSON.mockImplementation( + (..._args: unknown[]) => { + setTimeout(() => { + lastRegisteredCallback?.(0, "mock-content-pointer", null); + }, 0); + return "mock-task-pointer"; + }, + ); + mockFns.FMGeneratedContentGetJSONString.mockReturnValue("mock-json-pointer"); + decodeAndFreeStringMock.mockImplementation((pointer: unknown) => { + if (!pointer) return null; + return "not valid json {{{"; + }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Generate", + text: { + format: { + type: "json_schema", + name: "Test", + schema: { + type: "object", + properties: { a: { type: "string" } }, + }, + }, + }, + })) as Response; + + // Should fall through with the raw string instead of crashing + expect(result.status).toBe("completed"); + expect(result.output_text).toBe("not valid json {{{"); + client.close(); + }); + + it("function_call items in input become response transcript entries", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "user", content: "Get weather" }, + { + type: "function_call", + name: "get_weather", + arguments: '{"city":"LA"}', + call_id: "call_fc", + }, + { + type: "function_call_output", + call_id: "call_fc", + output: "Sunny", + }, + ], + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const responseEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "response", + ); + expect(responseEntries.length).toBeGreaterThan(0); + client.close(); + }); + }); + + describe("branch coverage — remaining paths", () => { + it("function_call_output mid-array (not trailing) enters else-if branch", async () => { + simulateRespondSuccess("Done"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "user", content: "Do A" }, + { + type: "function_call", + name: "tool_a", + arguments: "{}", + call_id: "call_mid", + }, + { + type: "function_call_output", + call_id: "call_mid", + output: "Result mid", + }, + { role: "user", content: "Now do B" }, + ], + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + // The function_call_output should appear as a user entry with [Tool result for ...] + const userEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "user", + ); + const toolResult = userEntries.find((e: { contents: { text: string }[] }) => + e.contents[0].text.includes("[Tool result"), + ); + expect(toolResult).toBeDefined(); + expect(toolResult.contents[0].text).toContain("Result mid"); + client.close(); + }); + + it("maps top_p only (without seed) to sampling mode", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + await client.responses.create({ + input: "test", + top_p: 0.8, + }); + + // Verify the call went through (sampling mode set internally) + expect(mockFns.FMLanguageModelSessionRespond).toHaveBeenCalled(); + client.close(); + }); + + it("maps seed only (without top_p) to sampling mode", async () => { + simulateRespondSuccess("test"); + + const client = new Client(); + await client.responses.create({ + input: "test", + seed: 123, + }); + + expect(mockFns.FMLanguageModelSessionRespond).toHaveBeenCalled(); + client.close(); + }); + + it("handles tool with null parameters", async () => { + simulateStructuredSuccess({ type: "text", content: "hi" }); + + const client = new Client(); + await client.responses.create({ + input: [{ role: "user", content: "test" }], + tools: [ + { + type: "function" as const, + name: "noop", + description: "Does nothing", + parameters: null, + }, + ], + }); + + expect(mockFns.FMLanguageModelSessionRespondWithSchemaFromJSON).toHaveBeenCalled(); + client.close(); + }); + + it("reorderJson handles extra keys not in schema and missing keys", async () => { + // Return object with an extra key not in schema + schema key missing from object + simulateStructuredSuccess({ name: "Alice", extra_field: "bonus" }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Generate", + text: { + format: { + type: "json_schema", + name: "Person", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, // missing from response + }, + }, + }, + }, + })) as Response; + + const parsed = JSON.parse(result.output_text); + // name should come first (from schema order), extra_field should follow + expect(Object.keys(parsed)).toEqual(["name", "extra_field"]); + expect(parsed.name).toBe("Alice"); + expect(parsed.extra_field).toBe("bonus"); + client.close(); + }); + + it("reorderJson handles non-object/array values and schemas without properties", async () => { + // Return an array value — orderKeys should pass it through + simulateStructuredSuccess({ items: [1, 2, 3], value: null }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Generate", + text: { + format: { + type: "json_schema", + name: "Test", + schema: { + type: "object", + properties: { + items: { type: "array" }, + value: { type: "string" }, + }, + }, + }, + }, + })) as Response; + + const parsed = JSON.parse(result.output_text); + expect(parsed.items).toEqual([1, 2, 3]); + expect(parsed.value).toBeNull(); + client.close(); + }); + + it("handles assistant messages in multi-turn array input", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello there" }, + { role: "user", content: "Thanks" }, + ], + }); + + const transcriptArg = String(mockFns.FMTranscriptCreateFromJSONString.mock.lastCall?.[0]); + const parsed = JSON.parse(transcriptArg); + const responseEntries = parsed.transcript.entries.filter( + (e: { role: string }) => e.role === "response", + ); + expect(responseEntries).toHaveLength(1); + expect(responseEntries[0].contents[0].text).toBe("Hello there"); + client.close(); + }); + + it("includes assistant messages in transcript", async () => { + simulateRespondSuccess("reply"); + + const client = new Client(); + const response = await client.responses.create({ + input: [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello!" }, + { role: "user", content: "How are you?" }, + ], + }); + + expect(response.output).toHaveLength(1); + expect(mockFns.FMLanguageModelSessionRespond).toHaveBeenCalled(); + const prompt = mockFns.FMLanguageModelSessionRespond.mock.calls[0][1] as string; + expect(prompt).toBe("How are you?"); + client.close(); + }); + + it("silently skips messages with unrecognized roles", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { role: "tool" as never, content: "ignored" }, + { role: "user", content: "Hello" }, + ], + }); + + expect(mockFns.FMLanguageModelSessionRespond).toHaveBeenCalled(); + client.close(); + }); + + it("ignores unknown item types in input array", async () => { + simulateRespondSuccess("OK"); + + const client = new Client(); + await client.responses.create({ + input: [ + { type: "some_unknown_type", data: "ignored" } as never, + { role: "user", content: "Hello" }, + ], + }); + + // Should not throw, unknown items are silently skipped + expect(mockFns.FMLanguageModelSessionRespond).toHaveBeenCalled(); + client.close(); + }); + + it("streaming structured output with schema omitted falls back to { type: 'object' }", async () => { + simulateStructuredSuccess({ value: 42 }); + + const client = new Client(); + const stream = await client.responses.create({ + input: "test", + stream: true, + text: { + format: { + type: "json_schema", + name: "Bare", + } as never, + }, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const last = events[events.length - 1]; + expect(last.type).toBe("response.completed"); + client.close(); + }); + + it("streaming refusal emits response.completed with refusal content", async () => { + simulateStreamError(9, "I cannot do that"); + + const client = new Client(); + const stream = await client.responses.create({ + input: "bad request", + stream: true, + }); + + const events: ResponseStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const completed = events.find((e) => e.type === "response.completed"); + expect(completed).toBeDefined(); + if (completed?.type === "response.completed") { + const msg = completed.response.output[0] as ResponseOutputMessage; + expect(msg.content[0].type).toBe("refusal"); + } + client.close(); + }); + }); + + describe("ResponseStream close() and cleanup", () => { + it("stream.close() disposes the session", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + mockFns.FMRelease.mockClear(); + stream.close(); + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + + it("stream.close() is idempotent", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + mockFns.FMRelease.mockClear(); + stream.close(); + stream.close(); + // FMRelease for session should only be called once + expect(mockFns.FMRelease).toHaveBeenCalledTimes(1); + client.close(); + }); + + it("session is cleaned up after full iteration", async () => { + simulateStreamSuccess(["Hello"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hi", + stream: true, + }); + + for await (const _event of stream) { + // consume all events + } + + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + + it("toReadableStream() returns a ReadableStream that yields events", async () => { + simulateStreamSuccess(["Hi"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + const readable = stream.toReadableStream(); + expect(readable).toBeInstanceOf(ReadableStream); + + const reader = readable.getReader(); + const events: ResponseStreamEvent[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + events.push(value); + } + expect(events.length).toBeGreaterThan(0); + expect(events[0].type).toBe("response.created"); + client.close(); + }); + + it("break during iteration calls cleanup via return()", async () => { + simulateStreamSuccess(["a", "b", "c"]); + + const client = new Client(); + const stream = await client.responses.create({ + input: "Hello", + stream: true, + }); + + mockFns.FMRelease.mockClear(); + for await (const _event of stream) { + break; + } + + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + client.close(); + }); + }); +}); diff --git a/tests/unit/compat/stream.test.ts b/tests/unit/compat/stream.test.ts new file mode 100644 index 0000000..7f3b95d --- /dev/null +++ b/tests/unit/compat/stream.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi } from "vitest"; + +type RegistryCallback = (cleanup: () => void) => void; + +const { getRegistryCallback } = vi.hoisted(() => { + let captured: RegistryCallback | null = null; + + globalThis.FinalizationRegistry = class MockFinalizationRegistry { + constructor(callback: RegistryCallback) { + captured = callback; + } + register() {} + unregister() {} + } as unknown as typeof FinalizationRegistry; + + return { + getRegistryCallback: () => captured, + }; +}); + +import { Stream } from "../../../src/compat/stream.js"; +import type { ChatCompletionChunk } from "../../../src/compat/types.js"; + +function makeChunk( + content: string | null, + finishReason: string | null = null, +): ChatCompletionChunk { + return { + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 1234567890, + model: "SystemLanguageModel", + choices: [ + { + index: 0, + delta: content != null ? { content } : {}, + finish_reason: finishReason as ChatCompletionChunk["choices"][0]["finish_reason"], + }, + ], + usage: null, + system_fingerprint: null, + }; +} + +async function* makeSource(chunks: ChatCompletionChunk[]): AsyncIterable { + for (const chunk of chunks) { + yield chunk; + } +} + +describe("Stream", () => { + it("is async iterable (for await works)", async () => { + const chunks = [makeChunk("hello")]; + const stream = new Stream(makeSource(chunks)); + + const results: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + results.push(chunk); + } + + expect(results).toHaveLength(1); + expect(results[0]).toEqual(chunks[0]); + }); + + it("yields multiple chunks in order", async () => { + const chunks = [makeChunk("hello"), makeChunk(" world"), makeChunk(null, "stop")]; + const stream = new Stream(makeSource(chunks)); + + const results: ChatCompletionChunk[] = []; + for await (const chunk of stream) { + results.push(chunk); + } + + expect(results).toHaveLength(3); + expect(results[0].choices[0].delta).toEqual({ content: "hello" }); + expect(results[1].choices[0].delta).toEqual({ content: " world" }); + expect(results[2].choices[0].finish_reason).toBe("stop"); + }); + + it("toReadableStream() returns a ReadableStream", () => { + const stream = new Stream(makeSource([])); + const readable = stream.toReadableStream(); + expect(readable).toBeInstanceOf(ReadableStream); + }); + + it("ReadableStream provides chunks correctly", async () => { + const chunks = [makeChunk("foo"), makeChunk("bar"), makeChunk(null, "stop")]; + const stream = new Stream(makeSource(chunks)); + const readable = stream.toReadableStream(); + + const reader = readable.getReader(); + const results: ChatCompletionChunk[] = []; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + results.push(value); + } + + expect(results).toHaveLength(3); + expect(results[0].choices[0].delta).toEqual({ content: "foo" }); + expect(results[1].choices[0].delta).toEqual({ content: "bar" }); + expect(results[2].choices[0].finish_reason).toBe("stop"); + }); + + it("close() invokes the cleanup callback", () => { + const cleanup = vi.fn(); + const stream = new Stream(makeSource([]), cleanup); + stream.close(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("close() is idempotent", () => { + const cleanup = vi.fn(); + const stream = new Stream(makeSource([]), cleanup); + stream.close(); + stream.close(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("break during iteration calls cleanup via return()", async () => { + const cleanup = vi.fn(); + const chunks = [makeChunk("a"), makeChunk("b"), makeChunk("c")]; + const stream = new Stream(makeSource(chunks), cleanup); + + for await (const _chunk of stream) { + break; + } + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("error during iteration calls cleanup", async () => { + const cleanup = vi.fn(); + async function* errorSource(): AsyncIterable { + yield makeChunk("a"); + throw new Error("boom"); + } + const stream = new Stream(errorSource(), cleanup); + + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("boom"); + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("break works when inner iterator has no return() method", async () => { + const cleanup = vi.fn(); + // Create an async iterable whose iterator lacks a return() method + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + let i = 0; + const chunks = [makeChunk("a"), makeChunk("b"), makeChunk("c")]; + return { + async next() { + if (i < chunks.length) { + return { done: false as const, value: chunks[i++] }; + } + return { done: true as const, value: undefined as unknown as ChatCompletionChunk }; + }, + // Intentionally no return() method + }; + }, + }; + const stream = new Stream(source, cleanup); + + for await (const _chunk of stream) { + break; + } + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("toReadableStream() propagates errors from the iterator", async () => { + async function* errorSource(): AsyncIterable { + yield makeChunk("ok"); + throw new Error("stream error"); + } + const stream = new Stream(errorSource()); + const readable = stream.toReadableStream(); + const reader = readable.getReader(); + + // First read succeeds + const first = await reader.read(); + expect(first.done).toBe(false); + + // Second read should reject with the error + await expect(reader.read()).rejects.toThrow("stream error"); + }); + + it("FinalizationRegistry callback invokes the cleanup function", () => { + const registryCallback = getRegistryCallback(); + expect(registryCallback).toBeTypeOf("function"); + const cleanup = vi.fn(); + registryCallback!(cleanup); + expect(cleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/compat/tools.test.ts b/tests/unit/compat/tools.test.ts new file mode 100644 index 0000000..aa64547 --- /dev/null +++ b/tests/unit/compat/tools.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, vi } from "vitest"; +import { + buildToolInstructions, + buildToolSchema, + parseToolResponse, +} from "../../../src/compat/tools.js"; +import type { ChatCompletionTool } from "../../../src/compat/types.js"; + +const sampleTools: ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather for a location", + parameters: { + type: "object", + properties: { + location: { type: "string", description: "The city and state" }, + }, + required: ["location"], + }, + }, + }, + { + type: "function", + function: { + name: "search_web", + description: "Search the web for information", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + }, +]; + +describe("buildToolInstructions", () => { + it("generates text containing tool names and descriptions", () => { + const result = buildToolInstructions(sampleTools); + expect(result).toContain("get_weather"); + expect(result).toContain("Get the current weather for a location"); + expect(result).toContain("search_web"); + expect(result).toContain("Search the web for information"); + }); + + it("mentions tool_call and text response types", () => { + const result = buildToolInstructions(sampleTools); + expect(result).toContain("tool_call"); + expect(result).toContain("text"); + }); + + it("includes serialized parameters", () => { + const result = buildToolInstructions(sampleTools); + expect(result).toContain('"location"'); + expect(result).toContain('"query"'); + }); + + it("handles tools with missing description and parameters", () => { + const tools: ChatCompletionTool[] = [ + { + type: "function", + function: { name: "bare_tool" }, + } as ChatCompletionTool, + ]; + const result = buildToolInstructions(tools); + expect(result).toContain("bare_tool"); + expect(result).toContain("parameters: {}"); + }); + + it("starts with a leading newline", () => { + const result = buildToolInstructions(sampleTools); + expect(result.startsWith("\n")).toBe(true); + }); + + it("includes tool use header and response rules", () => { + const result = buildToolInstructions(sampleTools); + expect(result).toContain("Tool Use Instructions"); + expect(result).toContain("Response Rules"); + }); +}); + +describe("buildToolSchema", () => { + it("returns an object with type object and required type field", () => { + const schema = buildToolSchema(sampleTools); + expect(schema.type).toBe("object"); + expect(schema.required).toEqual(["type"]); + expect(schema.additionalProperties).toBe(false); + }); + + it("has a type property with enum of text and tool_call", () => { + const schema = buildToolSchema(sampleTools); + const props = schema.properties as Record; + const typeProp = props.type as Record; + expect(typeProp.type).toBe("string"); + expect(typeProp.enum).toEqual(["tool_call", "text"]); + }); + + it("has a content string property", () => { + const schema = buildToolSchema(sampleTools); + const props = schema.properties as Record; + const contentProp = props.content as Record; + expect(contentProp.type).toBe("string"); + }); + + it("uses $ref for tool_call property", () => { + const schema = buildToolSchema(sampleTools); + const props = schema.properties as Record; + const toolCallProp = props.tool_call as Record; + expect(toolCallProp.$ref).toBe("#/$defs/ToolCall"); + }); + + it("defines ToolCall in $defs with correct structure", () => { + const schema = buildToolSchema(sampleTools); + const defs = schema.$defs as Record>; + const toolCallDef = defs.ToolCall; + expect(toolCallDef.type).toBe("object"); + expect(toolCallDef.title).toBe("ToolCall"); + expect(toolCallDef.required).toEqual(["name", "arguments"]); + expect(toolCallDef.additionalProperties).toBe(false); + }); + + it("populates tool name enum dynamically from provided tools", () => { + const schema = buildToolSchema(sampleTools); + const defs = schema.$defs as Record>; + const toolCallProps = defs.ToolCall.properties as Record>; + expect(toolCallProps.name.enum).toEqual(["get_weather", "search_web"]); + }); + + it("uses $ref for arguments in ToolCall def", () => { + const schema = buildToolSchema(sampleTools); + const defs = schema.$defs as Record>; + const toolCallProps = defs.ToolCall.properties as Record>; + expect(toolCallProps.arguments.$ref).toBe("#/$defs/ToolArguments"); + }); + + it("defines ToolArguments in $defs with merged properties", () => { + const schema = buildToolSchema(sampleTools); + const defs = schema.$defs as Record>; + const argsDef = defs.ToolArguments; + expect(argsDef.type).toBe("object"); + expect(argsDef.title).toBe("ToolArguments"); + expect(argsDef.additionalProperties).toBe(false); + const argProps = argsDef.properties as Record; + expect(argProps).toHaveProperty("location"); + expect(argProps).toHaveProperty("query"); + }); + + it("works with a single tool", () => { + const schema = buildToolSchema([sampleTools[0]]); + const defs = schema.$defs as Record>; + const toolCallProps = defs.ToolCall.properties as Record>; + expect(toolCallProps.name.enum).toEqual(["get_weather"]); + }); + + it("handles tools without parameters", () => { + const noParamsTool: ChatCompletionTool = { + type: "function", + function: { name: "no_args", description: "Tool with no params" }, + }; + const schema = buildToolSchema([noParamsTool]); + const defs = schema.$defs as Record>; + const argsDef = defs.ToolArguments; + expect(argsDef.type).toBe("object"); + expect(Object.keys(argsDef.properties as object)).toHaveLength(0); + }); + + it("throws when multiple tools define the same property name with different schemas", () => { + const tools: ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "tool_a", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + }, + }, + }, + { + type: "function", + function: { + name: "tool_b", + parameters: { + type: "object", + properties: { query: { type: "integer" } }, + }, + }, + }, + ]; + expect(() => buildToolSchema(tools)).toThrow( + /Tool parameter "query" is defined by both "tool_a" and "tool_b"/, + ); + }); + + it("allows multiple tools with the same property name if schemas match", () => { + const tools: ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "tool_a", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + }, + }, + }, + { + type: "function", + function: { + name: "tool_b", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + }, + }, + }, + ]; + const schema = buildToolSchema(tools); + const defs = schema.$defs as Record>; + const argProps = defs.ToolArguments.properties as Record; + expect(argProps).toHaveProperty("query"); + }); + + it("throws when duplicate tool names are provided", () => { + const tools: ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { location: { type: "string" } } }, + }, + }, + ]; + expect(() => buildToolSchema(tools)).toThrow(/Duplicate tool name "get_weather"/); + }); +}); + +describe("parseToolResponse", () => { + it("returns text result when type is text", () => { + const result = parseToolResponse({ type: "text", content: "Hello, world!" }); + expect(result.type).toBe("text"); + expect(result.content).toBe("Hello, world!"); + expect(result.toolCall).toBeUndefined(); + }); + + it("returns empty string content when type is text and content is missing", () => { + const result = parseToolResponse({ type: "text" }); + expect(result.type).toBe("text"); + expect(result.content).toBe(""); + }); + + it("returns tool_call result with id, type function, and stringified arguments", () => { + const result = parseToolResponse({ + type: "tool_call", + tool_call: { + name: "get_weather", + arguments: { location: "San Francisco, CA" }, + }, + }); + + expect(result.type).toBe("tool_call"); + expect(result.toolCall).toBeDefined(); + expect(result.toolCall!.id).toMatch(/^call_/); + expect(result.toolCall!.type).toBe("function"); + expect(result.toolCall!.function.name).toBe("get_weather"); + expect(result.toolCall!.function.arguments).toBe( + JSON.stringify({ location: "San Francisco, CA" }), + ); + expect(result.content).toBeUndefined(); + }); + + it("generates a unique id for each tool call", () => { + const r1 = parseToolResponse({ + type: "tool_call", + tool_call: { name: "search_web", arguments: { query: "test" } }, + }); + const r2 = parseToolResponse({ + type: "tool_call", + tool_call: { name: "search_web", arguments: { query: "test" } }, + }); + expect(r1.toolCall!.id).not.toBe(r2.toolCall!.id); + }); + + it("handles tool_call with missing arguments", () => { + const result = parseToolResponse({ + type: "tool_call", + tool_call: { name: "no_args" }, + }); + expect(result.type).toBe("tool_call"); + expect(result.toolCall!.function.arguments).toBe("{}"); + }); + + it("falls back to text with warning when type is tool_call but tool_call is missing", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = parseToolResponse({ type: "tool_call" }); + expect(result.type).toBe("text"); + expect(result.content).toBe(""); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("tool_call field is missing")); + warnSpy.mockRestore(); + }); + + it("falls back to text for unknown type", () => { + const result = parseToolResponse({ type: "unknown_type", content: "some text" }); + expect(result.type).toBe("text"); + expect(result.content).toBe("some text"); + }); + + it("normalizes non-object arguments to empty object", () => { + const result = parseToolResponse({ + type: "tool_call", + tool_call: { name: "test", arguments: "not an object" as never }, + }); + expect(result.type).toBe("tool_call"); + expect(result.toolCall!.function.arguments).toBe("{}"); + }); + + it("normalizes array arguments to empty object", () => { + const result = parseToolResponse({ + type: "tool_call", + tool_call: { name: "test", arguments: [1, 2, 3] as never }, + }); + expect(result.type).toBe("tool_call"); + expect(result.toolCall!.function.arguments).toBe("{}"); + }); + + it("normalizes null arguments to empty object", () => { + const result = parseToolResponse({ + type: "tool_call", + tool_call: { name: "test", arguments: null as never }, + }); + expect(result.type).toBe("tool_call"); + expect(result.toolCall!.function.arguments).toBe("{}"); + }); +}); diff --git a/tests/unit/compat/transcript.test.ts b/tests/unit/compat/transcript.test.ts new file mode 100644 index 0000000..6955125 --- /dev/null +++ b/tests/unit/compat/transcript.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect, vi } from "vitest"; +import { messagesToTranscript } from "../../../src/compat/transcript.js"; +import type { ChatCompletionMessageParam } from "../../../src/compat/types.js"; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(v: unknown): boolean { + return typeof v === "string" && UUID_RE.test(v); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +describe("messagesToTranscript", () => { + // 1. Single user message → empty entries, prompt set + it("single user message returns empty entries and sets prompt", () => { + const messages: ChatCompletionMessageParam[] = [{ role: "user", content: "Hello" }]; + const result = messagesToTranscript(messages); + expect(result.prompt).toBe("Hello"); + const parsed = JSON.parse(result.transcriptJson); + expect(parsed.transcript.entries).toHaveLength(0); + }); + + // 2. System message → instructions entry + it("system message becomes instructions entry", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "system", content: "You are a helper." }, + { role: "user", content: "Hi" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + expect(parsed.transcript.entries).toHaveLength(1); + const entry = parsed.transcript.entries[0]; + expect(entry.role).toBe("instructions"); + expect(entry.contents[0].text).toBe("You are a helper."); + expect(result.prompt).toBe("Hi"); + }); + + // 3. Developer role → same as system + it("developer role is treated the same as system", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "developer", content: "You are a dev assistant." }, + { role: "user", content: "Hey" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const entry = parsed.transcript.entries[0]; + expect(entry.role).toBe("instructions"); + expect(entry.contents[0].text).toBe("You are a dev assistant."); + }); + + // 4. Multiple system messages → first is instructions, rest are user with [System] prefix + it("subsequent system messages become user entries with [System] prefix", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "system", content: "First system." }, + { role: "system", content: "Second system." }, + { role: "user", content: "Hello" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + expect(parsed.transcript.entries).toHaveLength(2); + expect(parsed.transcript.entries[0].role).toBe("instructions"); + expect(parsed.transcript.entries[1].role).toBe("user"); + expect(parsed.transcript.entries[1].contents[0].text).toBe("[System] Second system."); + }); + + // 5. Multi-turn conversation (user/assistant/user) + it("multi-turn conversation maps correctly", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "And 3+3?" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + expect(parsed.transcript.entries).toHaveLength(2); + expect(parsed.transcript.entries[0].role).toBe("user"); + expect(parsed.transcript.entries[0].contents[0].text).toBe("What is 2+2?"); + expect(parsed.transcript.entries[1].role).toBe("response"); + expect(parsed.transcript.entries[1].contents[0].text).toBe("4"); + expect(result.prompt).toBe("And 3+3?"); + }); + + // 6. Assistant with tool_calls → response with stringified tool_calls + it("assistant with tool_calls stringifies the tool_calls as content", () => { + const toolCalls = [ + { id: "call_1", type: "function" as const, function: { name: "myFn", arguments: "{}" } }, + ]; + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Do something" }, + { role: "assistant", content: null, tool_calls: toolCalls }, + { role: "user", content: "Next" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const assistantEntry = parsed.transcript.entries[1]; + expect(assistantEntry.role).toBe("response"); + expect(assistantEntry.contents[0].text).toBe(JSON.stringify(toolCalls)); + }); + + // 7. Tool message → user entry with resolved tool name + it("tool message resolves name from previous assistant tool_calls", () => { + const toolCalls = [ + { + id: "call_abc", + type: "function" as const, + function: { name: "getWeather", arguments: "{}" }, + }, + ]; + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "What's the weather?" }, + { role: "assistant", content: null, tool_calls: toolCalls }, + { role: "tool", tool_call_id: "call_abc", content: "Sunny, 72°F" }, + { role: "user", content: "Thanks" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const toolEntry = parsed.transcript.entries[2]; + expect(toolEntry.role).toBe("user"); + expect(toolEntry.contents[0].text).toBe("[Tool result for getWeather]: Sunny, 72°F"); + }); + + // 8. Tool message with unresolvable tool_call_id → fallback format + it("tool message with unresolvable tool_call_id uses fallback format", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Do something" }, + { role: "tool", tool_call_id: "unknown_id", content: "some result" }, + { role: "user", content: "OK" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const toolEntry = parsed.transcript.entries[1]; + expect(toolEntry.role).toBe("user"); + expect(toolEntry.contents[0].text).toBe("[Tool result]: some result"); + }); + + // 9. Multi-part content arrays → extract text only + it("multi-part content arrays concatenate only text parts", () => { + const messages: ChatCompletionMessageParam[] = [ + { + role: "user", + content: [ + { type: "text", text: "Hello " }, + { type: "image_url", image_url: { url: "https://example.com/img.png" } }, + { type: "text", text: "world" }, + ], + }, + ]; + const result = messagesToTranscript(messages); + expect(result.prompt).toBe("Hello world"); + }); + + it("multi-part content in non-last user message extracts text parts", () => { + const messages: ChatCompletionMessageParam[] = [ + { + role: "user", + content: [ + { type: "text", text: "Part one " }, + { type: "image_url", image_url: { url: "https://example.com/img.png" } }, + { type: "text", text: "part two" }, + ], + }, + { role: "assistant", content: "Got it" }, + { role: "user", content: "Follow up" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + expect(parsed.transcript.entries[0].contents[0].text).toBe("Part one part two"); + }); + + // 10. Unique UUIDs on entries + it("every entry and content item has a unique UUID", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "system", content: "Instructions" }, + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "Bye" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const ids: string[] = []; + for (const entry of parsed.transcript.entries) { + expect(isUuid(entry.id)).toBe(true); + ids.push(entry.id); + for (const content of entry.contents) { + expect(isUuid(content.id)).toBe(true); + ids.push(content.id); + } + } + // All IDs are unique + expect(new Set(ids).size).toBe(ids.length); + }); + + // 11. Empty messages → throws + it("throws on empty messages array", () => { + expect(() => messagesToTranscript([])).toThrow(); + }); + + // 12. Last message not user (and not tool) → throws + it("throws when last message is not role user or tool", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello" }, + ]; + expect(() => messagesToTranscript(messages)).toThrow(); + }); + + // 13. Tool as last message → synthesizes user prompt from tool results + it("handles tool as last message by synthesizing user prompt", () => { + const toolCalls = [ + { + id: "call_123", + type: "function" as const, + function: { name: "get_weather", arguments: '{"city":"Tokyo"}' }, + }, + ]; + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "What's the weather?" }, + { role: "assistant", content: null, tool_calls: toolCalls }, + { + role: "tool", + tool_call_id: "call_123", + content: JSON.stringify({ temp: 22, condition: "Sunny" }), + }, + ]; + const result = messagesToTranscript(messages); + expect(result.prompt).toContain("[Tool result for get_weather]:"); + expect(result.prompt).toContain('"temp":22'); + const parsed = JSON.parse(result.transcriptJson); + // history should include user + assistant + the synthetic tool-result user appended + // but the synthetic user is sliced off as the last, so entries are: user, assistant(tool_calls), tool + expect(parsed.transcript.entries.length).toBeGreaterThanOrEqual(2); + }); + + // 14. Multiple tool results as last messages + it("handles multiple trailing tool messages", () => { + const toolCalls = [ + { + id: "call_a", + type: "function" as const, + function: { name: "tool_a", arguments: "{}" }, + }, + { + id: "call_b", + type: "function" as const, + function: { name: "tool_b", arguments: "{}" }, + }, + ]; + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Do both" }, + { role: "assistant", content: null, tool_calls: toolCalls }, + { role: "tool", tool_call_id: "call_a", content: "result_a" }, + { role: "tool", tool_call_id: "call_b", content: "result_b" }, + ]; + const result = messagesToTranscript(messages); + expect(result.prompt).toContain("[Tool result for tool_a]: result_a"); + expect(result.prompt).toContain("[Tool result for tool_b]: result_b"); + }); + + // 15. Tool as last message with unresolvable tool_call_id + it("tool as last message with unresolvable id uses fallback format", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Do something" }, + { role: "tool", tool_call_id: "unknown", content: "result" }, + ]; + const result = messagesToTranscript(messages); + expect(result.prompt).toBe("[Tool result]: result"); + }); + + // unknown role is silently ignored + it("ignores messages with unrecognized roles", () => { + const messages = [ + { role: "user", content: "Hi" }, + { role: "unknown_role", content: "ignored" }, + { role: "user", content: "Bye" }, + ] as ChatCompletionMessageParam[]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + // Only the first user message should appear as an entry + expect(parsed.transcript.entries).toHaveLength(1); + expect(parsed.transcript.entries[0].role).toBe("user"); + expect(result.prompt).toBe("Bye"); + }); + + // tool message scans past non-matching assistant tool_calls + it("tool message scans past assistant with non-matching tool_call ids", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "First" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_other", + type: "function" as const, + function: { name: "otherTool", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_missing", content: "result" }, + { role: "user", content: "Next" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const toolEntry = parsed.transcript.entries[2]; + expect(toolEntry.contents[0].text).toBe("[Tool result]: result"); + }); + + // extractText with null/undefined content + it("handles null content in a message", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "First" }, + { role: "assistant", content: null }, + { role: "user", content: "Second" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const assistantEntry = parsed.transcript.entries[1]; + expect(assistantEntry.role).toBe("response"); + expect(assistantEntry.contents[0].text).toBe(""); + }); + + // assistant without tool_calls uses extractText + it("assistant message without tool_calls extracts text content", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Plain response" }, + { role: "user", content: "Ok" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const entry = parsed.transcript.entries[1]; + expect(entry.role).toBe("response"); + expect(entry.contents[0].text).toBe("Plain response"); + }); + + // Additional: verify top-level transcript structure + it("returns correct top-level transcript structure", () => { + const messages: ChatCompletionMessageParam[] = [{ role: "user", content: "Test" }]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + expect(parsed.type).toBe("FoundationModels.Transcript"); + expect(parsed.version).toBe(1); + expect(parsed.transcript).toBeDefined(); + expect(Array.isArray(parsed.transcript.entries)).toBe(true); + }); + + // image_url content parts are warned and ignored + it("warns and ignores image_url content parts", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const messages: ChatCompletionMessageParam[] = [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image" }, + { type: "image_url", image_url: { url: "https://example.com/img.png" } }, + ], + }, + ]; + const result = messagesToTranscript(messages); + expect(result.prompt).toBe("Describe this image"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("image_url content parts are not supported"), + ); + warnSpy.mockRestore(); + }); + + // Additional: user entries have options: {} + it("user entries have options field", () => { + const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "First" }, + { role: "assistant", content: "Reply" }, + { role: "user", content: "Second" }, + ]; + const result = messagesToTranscript(messages); + const parsed = JSON.parse(result.transcriptJson); + const userEntry = parsed.transcript.entries[0]; + expect(userEntry.role).toBe("user"); + expect(userEntry.options).toEqual({}); + }); +}); diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index b8c3151..9891373 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -176,4 +176,13 @@ describe("SystemLanguageModel", () => { expect(mockFns.FMRelease).toHaveBeenCalledTimes(1); }); }); + + describe("Symbol.dispose", () => { + it("delegates to dispose()", () => { + const model = new SystemLanguageModel(); + model[Symbol.dispose](); + expect(model._nativeModel).toBeNull(); + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-model-pointer"); + }); + }); }); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts index d374849..350da4b 100644 --- a/tests/unit/errors.test.ts +++ b/tests/unit/errors.test.ts @@ -12,6 +12,7 @@ import { ConcurrentRequestsError, RefusalError, InvalidGenerationSchemaError, + ServiceCrashedError, GenerationError, FoundationModelsError, ToolCallError, @@ -115,6 +116,36 @@ describe("statusToError", () => { const err = statusToError(999, "something went wrong"); expect(err.message).toBe("Unknown error (code 999): something went wrong"); }); + + it("maps code 255 with SensitiveContentAnalysisML to ServiceCrashedError", () => { + const detail = + "Error FoundationModels.LanguageModelSession.GenerationError:-1 - UserInfo: " + + '["NSMultipleUnderlyingErrorsKey": [Error Domain=com.apple.SensitiveContentAnalysisML Code=15]]'; + const err = statusToError(255, detail); + expect(err).toBeInstanceOf(ServiceCrashedError); + expect(err.message).toContain("Apple Intelligence service has crashed"); + expect(err.message).toContain("launchctl kickstart"); + expect(err.message).toContain(detail); + }); + + it("maps code 255 with ModelManagerError Code=1013 to ServiceCrashedError", () => { + const detail = "ModelManagerServices.ModelManagerError Code=1013"; + const err = statusToError(255, detail); + expect(err).toBeInstanceOf(ServiceCrashedError); + }); + + it("maps code 255 with ModelManagerError Code=1041 to InvalidGenerationSchemaError", () => { + const detail = "ModelManagerServices.ModelManagerError Code=1041 - schema rejected"; + const err = statusToError(255, detail); + expect(err).toBeInstanceOf(InvalidGenerationSchemaError); + expect(err.message).toContain("rejected the schema"); + }); + + it("maps code 255 without crash signature to generic GenerationError", () => { + const err = statusToError(255, "some other error"); + expect(err).not.toBeInstanceOf(ServiceCrashedError); + expect(err).toBeInstanceOf(GenerationError); + }); }); describe("error hierarchy", () => { @@ -135,6 +166,7 @@ describe("error hierarchy", () => { expect(new ConcurrentRequestsError()).toBeInstanceOf(GenerationError); expect(new RefusalError()).toBeInstanceOf(GenerationError); expect(new InvalidGenerationSchemaError()).toBeInstanceOf(GenerationError); + expect(new ServiceCrashedError()).toBeInstanceOf(GenerationError); }); it("InvalidGenerationSchemaError extends GenerationError and FoundationModelsError", () => { @@ -171,6 +203,19 @@ describe("error hierarchy", () => { expect(new RefusalError().name).toBe("RefusalError"); expect(new InvalidGenerationSchemaError().name).toBe("InvalidGenerationSchemaError"); expect(new ToolCallError("t", new Error("x")).name).toBe("ToolCallError"); + expect(new ServiceCrashedError().name).toBe("ServiceCrashedError"); + }); + + it("ServiceCrashedError includes recovery instructions", () => { + const err = new ServiceCrashedError(); + expect(err.message).toContain("launchctl kickstart"); + expect(err.message).toContain("com.apple.generativeexperiencesd"); + }); + + it("ServiceCrashedError includes original error detail when provided", () => { + const err = new ServiceCrashedError("SensitiveContentAnalysisML Code=15"); + expect(err.message).toContain("SensitiveContentAnalysisML Code=15"); + expect(err.message).toContain("launchctl kickstart"); }); it("errors have default messages when constructed without arguments", () => { diff --git a/tests/unit/helpers/mock-bindings.ts b/tests/unit/helpers/mock-bindings.ts index 0dccaee..e4c04fd 100644 --- a/tests/unit/helpers/mock-bindings.ts +++ b/tests/unit/helpers/mock-bindings.ts @@ -36,7 +36,7 @@ export function createMockFunctions() { // Transcript FMLanguageModelSessionGetTranscriptJSONString: vi.fn(() => "mock-json-pointer"), - FMTranscriptCreateFromJSONString: vi.fn((): string | null => "mock-transcript-pointer"), + FMTranscriptCreateFromJSONString: vi.fn((_json: string): string | null => "mock-transcript-pointer"), // GenerationSchema FMGenerationSchemaCreate: vi.fn(() => "mock-schema-pointer"), diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts index 4959ed1..63250d9 100644 --- a/tests/unit/schema.test.ts +++ b/tests/unit/schema.test.ts @@ -65,6 +65,151 @@ describe("afmSchemaFormat", () => { expect(Object.keys(input)).not.toContain("title"); expect(Object.keys(input)).not.toContain("additionalProperties"); }); + + it("recursively normalizes nested object properties", () => { + const result = afmSchemaFormat({ + type: "object", + properties: { + nested: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }); + const nested = (result.properties as Record>).nested; + expect(nested.title).toBe("Object"); + expect(nested.required).toEqual([]); + expect(nested.additionalProperties).toBe(false); + expect(nested["x-order"]).toEqual(["name"]); + }); + + it("preserves explicit additionalProperties on nested objects", () => { + const result = afmSchemaFormat({ + type: "object", + properties: { + open: { + type: "object", + additionalProperties: true, + }, + }, + }); + const open = (result.properties as Record>).open; + expect(open.additionalProperties).toBe(true); + }); + + it("adds required to object types without it", () => { + const result = afmSchemaFormat({ type: "object" }); + expect(result.required).toEqual([]); + }); + + it("uses 'Object' as title for non-root objects", () => { + const result = afmSchemaFormat( + { type: "object", properties: { a: { type: "string" } } }, + false, + ); + expect(result.title).toBe("Object"); + }); + + it("passes through falsy property values without recursing", () => { + const result = afmSchemaFormat({ + type: "object", + properties: { empty: null as unknown as Record }, + }); + const props = result.properties as Record; + expect(props.empty).toBeNull(); + }); + + it("recursively normalizes $defs entries", () => { + const result = afmSchemaFormat({ + $defs: { + Inner: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + type: "object", + properties: { + ref: { $ref: "#/$defs/Inner" }, + }, + }); + const defs = result.$defs as Record>; + expect(defs.Inner.title).toBe("Object"); + expect(defs.Inner.required).toEqual([]); + expect(defs.Inner.additionalProperties).toBe(false); + expect(defs.Inner["x-order"]).toEqual(["name"]); + }); + + it("passes through non-object $defs entries unchanged", () => { + const result = afmSchemaFormat({ + $defs: { + Inner: { + type: "object", + properties: { name: { type: "string" } }, + }, + Alias: "string" as unknown as Record, + }, + type: "object", + properties: {}, + }); + const defs = result.$defs as Record; + expect(defs.Alias).toBe("string"); + }); + + it("preserves $ref properties without recursing into them", () => { + const result = afmSchemaFormat({ + type: "object", + properties: { + nested: { $ref: "#/$defs/Something", description: "A reference" }, + }, + }); + const props = result.properties as Record>; + expect(props.nested.$ref).toBe("#/$defs/Something"); + expect(props.nested.description).toBe("A reference"); + // Should NOT have title, additionalProperties, etc. added + expect(props.nested.title).toBeUndefined(); + }); + + it("recursively normalizes array items with nested objects", () => { + const result = afmSchemaFormat({ + type: "object", + properties: { + people: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + }, + }, + }, + }, + }); + const props = result.properties as Record>; + const items = props.people.items as Record; + expect(items.title).toBe("Object"); + expect(items.required).toEqual([]); + expect(items.additionalProperties).toBe(false); + expect(items["x-order"]).toEqual(["name", "age"]); + }); + + it("skips array items with $ref", () => { + const result = afmSchemaFormat({ + type: "object", + properties: { + people: { + type: "array", + items: { $ref: "#/$defs/Person" }, + }, + }, + }); + const props = result.properties as Record>; + const items = props.people.items as Record; + expect(items.$ref).toBe("#/$defs/Person"); + expect(items.title).toBeUndefined(); + }); }); describe("GenerationGuide", () => { diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts index 662efd5..b5e2796 100644 --- a/tests/unit/session.test.ts +++ b/tests/unit/session.test.ts @@ -178,6 +178,64 @@ describe("LanguageModelSession", () => { }); }); + describe("process exit cleanup", () => { + it("disposes live sessions on process exit", () => { + // Dispose any sessions left over from prior tests to isolate this test + process.emit("exit", 0); + + const s1 = new LanguageModelSession(); + const s2 = new LanguageModelSession(); + expect(s1._nativeSession).not.toBeNull(); + expect(s2._nativeSession).not.toBeNull(); + + // Simulate process exit — triggers the cleanup handler + process.emit("exit", 0); + + expect(s1._nativeSession).toBeNull(); + expect(s2._nativeSession).toBeNull(); + }); + + it("does not fail if sessions are already disposed before exit", () => { + process.emit("exit", 0); // clear any leftovers + + const session = new LanguageModelSession(); + session.dispose(); + vi.clearAllMocks(); + + // Should not throw or double-release + process.emit("exit", 0); + expect(mockFns.FMRelease).not.toHaveBeenCalled(); + }); + }); + + describe("signal handlers", () => { + it("cleans up sessions and re-raises on SIGINT", () => { + process.emit("exit", 0); // clear leftovers + + const session = new LanguageModelSession(); + expect(session._nativeSession).not.toBeNull(); + + // Capture the SIGINT listener that _installExitHandler registered + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + // Emit SIGINT to trigger the handler + process.emit("SIGINT", "SIGINT"); + + expect(session._nativeSession).toBeNull(); + expect(killSpy).toHaveBeenCalledWith(process.pid, "SIGINT"); + killSpy.mockRestore(); + }); + }); + + describe("Symbol.dispose", () => { + it("delegates to dispose()", () => { + const session = new LanguageModelSession(); + session[Symbol.dispose](); + expect(session._nativeSession).toBeNull(); + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-session-pointer"); + }); + }); + describe("cancel", () => { it("does nothing when no active task and pointer is null", () => { const session = new LanguageModelSession(); @@ -583,7 +641,7 @@ describe("LanguageModelSession", () => { ); const session = new LanguageModelSession(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of session.streamResponse("Hi", { options: { temperature: 0.8 }, })) { @@ -737,4 +795,13 @@ describe("LanguageModelSession", () => { expect(() => cleanup!("bad-pointer")).not.toThrow(); }); }); + + describe("transcript getter guard", () => { + it("throws when transcript is accessed on uninitialized session", () => { + // Create session then null out internal transcript to test guard + const session = new LanguageModelSession(); + (session as unknown as { _transcript: null })._transcript = null; + expect(() => session.transcript).toThrow("Session not initialized"); + }); + }); }); diff --git a/tests/unit/tool.test.ts b/tests/unit/tool.test.ts index c0d7a01..fc779c9 100644 --- a/tests/unit/tool.test.ts +++ b/tests/unit/tool.test.ts @@ -40,6 +40,10 @@ vi.mock("../../src/bindings.js", () => ({ ToolCallbackProto: "ToolCallbackProto", })); +const { shouldThrowOnConstruct } = vi.hoisted(() => ({ + shouldThrowOnConstruct: { value: false as boolean | string }, +})); + vi.mock("../../src/schema.js", () => ({ GenerationSchema: class MockSchema { _nativeSchema = "mock-schema-pointer"; @@ -47,6 +51,8 @@ vi.mock("../../src/schema.js", () => ({ GeneratedContent: class MockContent { _nativeContent: unknown; constructor(pointer: unknown) { + if (shouldThrowOnConstruct.value === true) throw new Error("construct failed"); + if (shouldThrowOnConstruct.value) throw shouldThrowOnConstruct.value; this._nativeContent = pointer; } }, @@ -246,6 +252,64 @@ describe("Tool", () => { const contentArg = callSpy.mock.calls[0][0]; expect(contentArg._nativeContent).toBe("special-content-ref"); }); + + it("finishes call with error when GeneratedContent constructor throws synchronously", async () => { + const tool = new TestTool(); + tool._register(); + + shouldThrowOnConstruct.value = true; + try { + const callback = capturedCallbacks[0]; + callback("bad-content-ref", 55); + + await vi.waitFor(() => { + expect(mockFns.FMBridgedToolFinishCall).toHaveBeenCalledTimes(1); + }); + + expect(mockFns.FMBridgedToolFinishCall).toHaveBeenCalledWith( + "mock-tool-pointer", + 55, + "Tool callback error: construct failed", + ); + } finally { + shouldThrowOnConstruct.value = false; + } + }); + + it("handles non-Error synchronous throws in callback", async () => { + const tool = new TestTool(); + tool._register(); + + shouldThrowOnConstruct.value = "raw string crash"; + try { + const callback = capturedCallbacks[0]; + callback("bad-content-ref", 66); + + await vi.waitFor(() => { + expect(mockFns.FMBridgedToolFinishCall).toHaveBeenCalledTimes(1); + }); + + expect(mockFns.FMBridgedToolFinishCall).toHaveBeenCalledWith( + "mock-tool-pointer", + 66, + "Tool callback error: raw string crash", + ); + } finally { + shouldThrowOnConstruct.value = false; + } + }); + }); + + describe("Symbol.dispose", () => { + it("delegates to dispose()", () => { + const tool = new TestTool(); + tool._register(); + vi.clearAllMocks(); + tool[Symbol.dispose](); + expect(mockKoffi.unregister).toHaveBeenCalledWith("mock-cb-pointer"); + expect(mockFns.FMRelease).toHaveBeenCalledWith("mock-tool-pointer"); + expect(tool._nativeTool).toBeNull(); + }); }); describe("FinalizationRegistry cleanup", () => { diff --git a/tests/unit/transcript.test.ts b/tests/unit/transcript.test.ts index 2c866ba..5c111f2 100644 --- a/tests/unit/transcript.test.ts +++ b/tests/unit/transcript.test.ts @@ -3,9 +3,9 @@ import { createMockFunctions } from "./helpers/mock-bindings.js"; const mockFns = createMockFunctions(); const { mockDecodeAndFreeString } = vi.hoisted(() => ({ - mockDecodeAndFreeString: vi.fn((pointer: unknown) => { - if (!pointer) return null; - return '{"type":"transcript","entries":[]}'; + mockDecodeAndFreeString: vi.fn((_pointer: unknown): string | null => { + if (!_pointer) return null; + return '{"type":"FoundationModels.Transcript","version":1,"transcript":{"entries":[]}}'; }), })); @@ -23,7 +23,7 @@ beforeEach(() => { vi.clearAllMocks(); mockDecodeAndFreeString.mockImplementation((pointer: unknown) => { if (!pointer) return null; - return '{"type":"transcript","entries":[]}'; + return '{"type":"FoundationModels.Transcript","version":1,"transcript":{"entries":[]}}'; }); }); @@ -32,7 +32,9 @@ describe("Transcript", () => { it("returns JSON string from C API", () => { const transcript = new Transcript(mockPointer("mock-session")); const json = transcript.toJson(); - expect(json).toBe('{"type":"transcript","entries":[]}'); + expect(json).toBe( + '{"type":"FoundationModels.Transcript","version":1,"transcript":{"entries":[]}}', + ); expect(mockFns.FMLanguageModelSessionGetTranscriptJSONString).toHaveBeenCalledWith( "mock-session", null, @@ -51,7 +53,11 @@ describe("Transcript", () => { it("returns parsed JSON object", () => { const transcript = new Transcript(mockPointer("mock-session")); const dict = transcript.toDict(); - expect(dict).toEqual({ type: "transcript", entries: [] }); + expect(dict).toEqual({ + type: "FoundationModels.Transcript", + version: 1, + transcript: { entries: [] }, + }); }); }); @@ -85,6 +91,130 @@ describe("Transcript", () => { }); }); + describe("entries", () => { + it("returns typed entries from a simple transcript", () => { + const json = JSON.stringify({ + type: "FoundationModels.Transcript", + version: 1, + transcript: { + entries: [ + { + id: "e1", + role: "instructions", + contents: [{ type: "text", text: "You are helpful.", id: "c1" }], + }, + { + id: "e2", + role: "user", + contents: [{ type: "text", text: "Hello", id: "c2" }], + }, + { + id: "e3", + role: "response", + contents: [{ type: "text", text: "Hi there!", id: "c3" }], + }, + ], + }, + }); + mockDecodeAndFreeString.mockReturnValueOnce(json); + const transcript = new Transcript(mockPointer("mock-session")); + const entries = transcript.entries(); + + expect(entries).toHaveLength(3); + expect(entries[0].role).toBe("instructions"); + expect(entries[1].role).toBe("user"); + expect(entries[2].role).toBe("response"); + expect(entries[0].contents?.[0]).toEqual({ + type: "text", + text: "You are helpful.", + id: "c1", + }); + }); + + it("returns entries with tool calls and tool output", () => { + const json = JSON.stringify({ + type: "FoundationModels.Transcript", + version: 1, + transcript: { + entries: [ + { + id: "e1", + role: "response", + toolCalls: [{ id: "tc1", name: "get_weather", arguments: '{"city":"SF"}' }], + }, + { + id: "e2", + role: "tool", + contents: [{ type: "text", text: '{"temp":72}', id: "c1" }], + toolName: "get_weather", + toolCallID: "tc1", + }, + ], + }, + }); + mockDecodeAndFreeString.mockReturnValueOnce(json); + const transcript = new Transcript(mockPointer("mock-session")); + const entries = transcript.entries(); + + expect(entries).toHaveLength(2); + expect(entries[0].toolCalls?.[0]).toEqual({ + id: "tc1", + name: "get_weather", + arguments: '{"city":"SF"}', + }); + expect(entries[1].role).toBe("tool"); + expect(entries[1].toolName).toBe("get_weather"); + expect(entries[1].toolCallID).toBe("tc1"); + }); + + it("returns entries with structured content", () => { + const json = JSON.stringify({ + type: "FoundationModels.Transcript", + version: 1, + transcript: { + entries: [ + { + id: "e1", + role: "response", + contents: [ + { + type: "structure", + id: "s1", + structure: { source: '{"name":"Ada"}', content: { name: "Ada" } }, + }, + ], + }, + ], + }, + }); + mockDecodeAndFreeString.mockReturnValueOnce(json); + const transcript = new Transcript(mockPointer("mock-session")); + const entries = transcript.entries(); + + expect(entries).toHaveLength(1); + const content = entries[0].contents?.[0]; + expect(content?.type).toBe("structure"); + if (content?.type === "structure") { + expect(content.structure.content).toEqual({ name: "Ada" }); + } + }); + + it("returns empty array for transcript with no entries", () => { + const transcript = new Transcript(mockPointer("mock-session")); + const entries = transcript.entries(); + expect(entries).toEqual([]); + }); + + it("returns empty array when entries key is missing from JSON", () => { + mockDecodeAndFreeString.mockReturnValueOnce( + '{"type":"FoundationModels.Transcript","version":1,"transcript":{}}', + ); + const transcript = new Transcript(mockPointer("mock-session")); + const entries = transcript.entries(); + expect(entries).toEqual([]); + }); + }); + describe("_updateNativeSession", () => { it("updates the internal session pointer", () => { const transcript = new Transcript(mockPointer("old-session")); diff --git a/tsconfig.json b/tsconfig.json index 5737581..b77d896 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": [ - "ES2022" + "ES2022", + "ESNext.Disposable" ], "outDir": "./dist", "rootDir": "./src", diff --git a/vitest.config.ts b/vitest.config.ts index 54fb8e7..2b4fb46 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,11 +2,30 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - globals: true, - include: ["tests/unit/**/*.test.ts"], coverage: { include: ["src/**"], - exclude: ["src/index.ts", "src/bindings.ts"], + exclude: ["src/index.ts", "src/bindings.ts", "src/compat/types.ts"], }, + projects: [ + { + extends: true, + test: { + name: "unit", + globals: true, + include: ["tests/unit/**/*.test.ts"], + }, + }, + { + extends: true, + test: { + name: "integration", + globals: true, + include: ["tests/integration/**/*.test.ts"], + pool: "forks", + fileParallelism: false, + testTimeout: 30_000, + }, + }, + ], }, }); diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index b44320a..0000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1 +0,0 @@ -export default ["vitest.config.ts", "tests/integration/vitest.config.ts"];