Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion sdk/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,43 @@ for await (const chunk of audioClient.transcribeStreaming('/path/to/audio.wav'))
}
```

### Responses API

Use the Responses API client for OpenAI-compatible text, tool, streaming, stored-response, and vision workflows over the embedded web service:

```typescript
import { createImageContentFromFile, getOutputText } from 'foundry-local-sdk';

manager.startWebService();

const client = manager.createResponsesClient(model.id);

// Responses are stored by default so they can be retrieved or listed later.
// Set store=false per client or request to opt out.
client.settings.store = false;

const response = await client.create('Tell me a short joke.');
console.log(getOutputText(response));

const storedResponses = await client.list({ limit: 10, order: 'desc' });
console.log(storedResponses.has_more);

const image = await createImageContentFromFile('/path/to/image.png');
const visionResponse = await client.create([
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'Describe this image.' },
image,
],
},
]);
console.log(getOutputText(visionResponse));
```

Vision helpers support `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, and `.bmp` files. `createImageContentFromFile()` sends Foundry Local's server contract (`image_data` plus `media_type`); `createImageContentFromUrl()` sends `image_url` and lets the server infer the media type.

### Embedded Web Service

Start a local HTTP server that exposes an OpenAI-compatible API:
Expand Down Expand Up @@ -288,6 +325,7 @@ Auto-generated class documentation lives in [`docs/classes/`](docs/classes/):
- [IModel](docs/README.md#imodel) — Model interface: variant selection, download, load, inference
- [ChatClient](docs/classes/ChatClient.md) — Chat completions (sync and streaming)
- [AudioClient](docs/classes/AudioClient.md) — Audio transcription (sync and streaming)
- [ResponsesClient](docs/classes/ResponsesClient.md) — Responses API (text, streaming, tools, stored responses, vision)
- [ModelLoadManager](docs/classes/ModelLoadManager.md) — Low-level model loading management

## Contributing: Building from Source
Expand Down Expand Up @@ -336,4 +374,4 @@ See `test/README.md` for details on prerequisites and setup.
npm run example
```

This runs the chat completion example in `examples/chat-completion.ts`.
This runs the chat completion example in `examples/chat-completion.ts`.
44 changes: 42 additions & 2 deletions sdk/js/examples/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
// Licensed under the MIT License.
// -------------------------------------------------------------------------

import { FoundryLocalManager, getOutputText } from '../src/index.js';
import type { StreamingEvent, FunctionToolDefinition, FunctionCallItem } from '../src/types.js';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { FoundryLocalManager, getOutputText, createImageContentFromFile } from '../src/index.js';
import type { StreamingEvent, FunctionToolDefinition, FunctionCallItem, MessageItem } from '../src/types.js';

async function main() {
try {
Expand Down Expand Up @@ -121,6 +124,43 @@ async function main() {
const deleted = await client.delete(stored.id);
console.log(`Deleted: ${deleted.deleted}`);

// =================================================================
// Example 6: List all stored responses
// =================================================================
console.log('\n--- Example 6: List stored responses ---');
const allResponses = await client.list();
console.log(`Listed ${allResponses.data.length} stored responses`);

// =================================================================
// Example 7: Vision — describe an image
// =================================================================
console.log('\n--- Example 7: Vision ---');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'foundry-responses-example-'));
const testImagePath = path.join(tempDir, 'sample.png');
// Minimal 1x1 PNG so the example runs without external assets.
const samplePng = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108020000009001' +
'2e00000000c4944415478016360f8cfc000000002000176dd24100000000049454e44ae426082',
'hex'
);
fs.writeFileSync(testImagePath, samplePng);
try {
const imageContent = await createImageContentFromFile(testImagePath);
const visionResponse = await client.create([
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'Describe this image in one sentence.' },
imageContent,
],
} as MessageItem,
]);
console.log(`Vision: ${getOutputText(visionResponse)}`);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}

// Cleanup
manager.stopWebService();
await model.unload();
Expand Down
1 change: 1 addition & 0 deletions sdk/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { EmbeddingClient } from './openai/embeddingClient.js';
export { LiveAudioTranscriptionSession, LiveAudioTranscriptionOptions } from './openai/liveAudioTranscriptionClient.js';
export type { LiveAudioTranscriptionResponse, TranscriptionContentPart } from './openai/liveAudioTranscriptionTypes.js';
export { ResponsesClient, ResponsesClientSettings, getOutputText } from './openai/responsesClient.js';
export { createImageContentFromFile, createImageContentFromUrl } from './openai/vision.js';
export { ModelLoadManager } from './detail/modelLoadManager.js';
/** @internal */
export { CoreInterop } from './detail/coreInterop.js';
Expand Down
27 changes: 26 additions & 1 deletion sdk/js/src/openai/responsesClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
StreamingEvent,
InputItemsListResponse,
DeleteResponseResult,
ListResponsesResult,
ListResponsesOptions,
ResponseInputItem,
MessageItem,
ContentPart,
Expand Down Expand Up @@ -52,6 +54,11 @@ export class ResponsesClientSettings {
toolChoice?: ResponseToolChoice;
truncation?: TruncationStrategy;
parallelToolCalls?: boolean;
/**
* Whether to store the response server-side so it can be retrieved via `get()`, `list()`,
* `getInputItems()`, or referenced by `previous_response_id`. Defaults to `true` when not
* explicitly set. Set to `false` to disable persistence for a given client.
*/
store?: boolean;
metadata?: Record<string, string>;
reasoning?: ReasoningConfig;
Expand All @@ -76,7 +83,8 @@ export class ResponsesClientSettings {
tool_choice: this.toolChoice,
truncation: this.truncation,
parallel_tool_calls: this.parallelToolCalls,
store: this.store,
// Default store to true when not explicitly set
store: this.store !== undefined ? this.store : true,
Comment thread
MaanavD marked this conversation as resolved.
Outdated
metadata: this.metadata,
reasoning: this.reasoning ? filterUndefined(this.reasoning) : undefined,
text: this.text ? filterUndefined(this.text) : undefined,
Expand Down Expand Up @@ -275,6 +283,23 @@ export class ResponsesClient {
);
}

/**
* Lists stored responses.
* @param options - Optional pagination parameters. The Foundry Local server supports
* `limit`, `order`, and `after`; it does not currently support `before`.
* @returns The list of Response objects.
*/
public async list(options?: ListResponsesOptions): Promise<ListResponsesResult> {
const query = new URLSearchParams();
if (options?.limit !== undefined) query.set('limit', String(options.limit));
if (options?.order !== undefined) query.set('order', options.order);
if (options?.after !== undefined) query.set('after', options.after);

const queryString = query.toString();
const path = queryString ? `/v1/responses?${queryString}` : '/v1/responses';
return this.fetchJson<ListResponsesResult>(path, { method: 'GET' });
}

// ========================================================================
// Internal helpers
// ========================================================================
Expand Down
159 changes: 159 additions & 0 deletions sdk/js/src/openai/vision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// -------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
// -------------------------------------------------------------------------

import * as path from 'path';
import { promises as fsPromises } from 'fs';
import type { InputImageContent } from '../types.js';

const MEDIA_TYPE_MAP: Record<string, string> = {
'.png': 'image/png',
Comment thread
apsonawane marked this conversation as resolved.
Outdated
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
};

/**
* Options for `createImageContentFromFile`.
*/
export interface ImageContentOptions {
/** Detail level hint for the model. */
detail?: 'low' | 'high' | 'auto';
/**
* If set, the longest dimension of the image will be scaled down to this value
* (preserving aspect ratio) before encoding. Must be a finite positive integer.
* Requires the `sharp` package to be installed as an optional peer dependency
* (`npm install sharp`). If `sharp` is not available, a warning is printed and
* the original image is used unresized.
*/
maxDimension?: number;
}

/**
* Creates an `InputImageContent` part by reading an image file from disk.
* The file is base64-encoded and embedded directly in the content part.
* Supported file extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`.
*
* The second argument accepts either an `ImageContentOptions` object or a shorthand
* detail string (`'low' | 'high' | 'auto'`) for convenience.
*
* @param filePath - Absolute or relative path to the image file.
* @param options - Optional `ImageContentOptions`, or a shorthand detail string.
* @returns A `Promise<InputImageContent>` with base64-encoded image data.
* @throws If the file does not exist, the extension is unsupported, or `maxDimension`
* is not a finite positive integer.
*/
export async function createImageContentFromFile(
filePath: string,
options?: ImageContentOptions | 'low' | 'high' | 'auto'
): Promise<InputImageContent> {
// Support the shorthand signature: createImageContentFromFile(path, detail?)
const opts: ImageContentOptions = typeof options === 'string'
? { detail: options }
: (options ?? {});

if (opts.maxDimension !== undefined) {
if (!Number.isFinite(opts.maxDimension) || !Number.isInteger(opts.maxDimension) || opts.maxDimension <= 0) {
throw new Error(`Invalid maxDimension: ${opts.maxDimension}. Expected a finite positive integer.`);
}
}

const ext = path.extname(filePath).toLowerCase();
const mediaType = MEDIA_TYPE_MAP[ext];
if (!mediaType) {
throw new Error(
`Unsupported image format: ${ext}. Supported formats: ${Object.keys(MEDIA_TYPE_MAP).join(', ')}`
);
}

let dataBuffer: Buffer;
try {
dataBuffer = await fsPromises.readFile(filePath) as Buffer;
} catch (err: any) {
if (err.code === 'ENOENT') {
throw new Error(`Image file not found: ${filePath}`);
}
throw err;
}

let finalMediaType = mediaType;
if (opts.maxDimension !== undefined) {
Comment thread
MaanavD marked this conversation as resolved.
Outdated
const resized = await resizeImage(dataBuffer, opts.maxDimension, mediaType);
dataBuffer = resized.buffer;
finalMediaType = resized.mediaType;
}

const content: InputImageContent = {
type: 'input_image',
image_data: dataBuffer.toString('base64'),
media_type: finalMediaType,
};
if (opts.detail !== undefined) {
content.detail = opts.detail;
}
return content;
}

/**
* Creates an `InputImageContent` part from a URL.
* The server will infer the media type from the URL.
*
* @param url - Public URL pointing to the image.
* @param detail - Optional detail level hint for the model ('low' | 'high' | 'auto').
* @returns An `InputImageContent` object with the image URL.
*/
export function createImageContentFromUrl(url: string, detail?: 'low' | 'high' | 'auto'): InputImageContent {
const content: InputImageContent = {
type: 'input_image',
image_url: url,
// media_type intentionally omitted — server infers from URL
};
if (detail !== undefined) {
content.detail = detail;
}
return content;
}

/**
* Attempts to resize image data to fit within `maxDimension` on the longest side.
* Requires the optional `sharp` peer dependency. Falls back to original data with a
* warning if `sharp` is not available.
* Returns both the (possibly resized) buffer and the media type.
*/
async function resizeImage(data: Buffer, maxDimension: number, fallbackMediaType: string): Promise<{ buffer: Buffer; mediaType: string }> {
let sharp: any;
try {
// Dynamic import so sharp remains a soft/optional peer dep.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — sharp is an optional peer dependency
sharp = (await import('sharp')).default;
} catch {
console.warn(
`[foundry-local] createImageContentFromFile: maxDimension=${maxDimension} requires the ` +
`"sharp" package (npm install sharp). Image will be used unresized.`
);
return { buffer: data, mediaType: fallbackMediaType };
}

const metadata = await sharp(data).metadata();
const { width = 0, height = 0, format } = metadata;
// Map sharp format names back to MIME types; fall back to the original type
const formatToMime: Record<string, string> = {
png: 'image/png', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', bmp: 'image/bmp',
};
const mediaType = (format && formatToMime[format]) ?? fallbackMediaType;

if (Math.max(width, height) <= maxDimension) {
return { buffer: data, mediaType };
}

const resizedBuffer: Buffer = await sharp(data)
.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true })
.toBuffer();

return { buffer: resizedBuffer, mediaType };
}
Loading
Loading