diff --git a/docs/adapters/nebius.md b/docs/adapters/nebius.md new file mode 100644 index 00000000..bda488da --- /dev/null +++ b/docs/adapters/nebius.md @@ -0,0 +1,271 @@ +--- +title: Nebius Token Factory +id: nebius-adapter +order: 5 +--- + +The Nebius Token Factory adapter provides access to various LLMs hosted on Nebius Token Factory, including DeepSeek, Llama, Qwen, and more. Nebius Token Factory offers an OpenAI-compatible API, making it easy to use these models with TanStack AI. + +## Installation + +```bash +npm install @tanstack/ai-nebius +``` + +## Basic Usage + +```typescript +import { chat } from "@tanstack/ai"; +import { nebiusText } from "@tanstack/ai-nebius"; + +const stream = chat({ + adapter: nebiusText("deepseek-ai/DeepSeek-R1-0528"), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +## Basic Usage - Custom API Key + +```typescript +import { chat } from "@tanstack/ai"; +import { createNebiusChat } from "@tanstack/ai-nebius"; + +const adapter = createNebiusChat( + "deepseek-ai/DeepSeek-R1-0528", + "your-api-key-here" +); + +const stream = chat({ + adapter, + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +## Configuration + +```typescript +import { createNebiusChat } from "@tanstack/ai-nebius"; + +// With explicit API key +const adapter = createNebiusChat("deepseek-ai/DeepSeek-R1-0528", "your-api-key"); + +// With custom base URL (optional, defaults to Nebius endpoint) +const adapter = createNebiusChat( + "deepseek-ai/DeepSeek-R1-0528", + "your-api-key", + { baseURL: "https://api.tokenfactory.nebius.com/v1/" } +); +``` + +## Available Models + +Nebius Token Factory offers a variety of models. You can use any model name as a string, but here are some popular options: + +### DeepSeek Models +- `deepseek-ai/DeepSeek-R1-0528` - DeepSeek R1 reasoning model +- `deepseek-ai/DeepSeek-V3-0324` - DeepSeek V3 model + +### Llama Models +- `meta-llama/Meta-Llama-3.1-70B-Instruct` - Llama 3.1 70B +- `meta-llama/Meta-Llama-3.1-8B-Instruct` - Llama 3.1 8B +- `meta-llama/Meta-Llama-3.1-405B-Instruct` - Llama 3.1 405B + +### Qwen Models +- `Qwen/Qwen2.5-72B-Instruct` - Qwen 2.5 72B +- `Qwen/Qwen2.5-7B-Instruct` - Qwen 2.5 7B +- `Qwen/Qwen2.5-32B-Instruct` - Qwen 2.5 32B + +To see all available models, check the [Nebius Token Factory documentation](https://docs.tokenfactory.nebius.com/ai-models-inference/overview). + +## Example: Chat Completion + +```typescript +import { chat, toStreamResponse } from "@tanstack/ai"; +import { nebiusText } from "@tanstack/ai-nebius"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: nebiusText("deepseek-ai/DeepSeek-R1-0528"), + messages, + }); + + return toStreamResponse(stream); +} +``` + +## Example: With Tools + +```typescript +import { chat, toolDefinition } from "@tanstack/ai"; +import { nebiusText } from "@tanstack/ai-nebius"; +import { z } from "zod"; + +const getWeatherDef = toolDefinition({ + name: "get_weather", + description: "Get the current weather", + inputSchema: z.object({ + location: z.string(), + }), +}); + +const getWeather = getWeatherDef.server(async ({ location }) => { + // Fetch weather data + return { temperature: 72, conditions: "sunny" }; +}); + +const stream = chat({ + adapter: nebiusText("deepseek-ai/DeepSeek-R1-0528"), + messages, + tools: [getWeather], +}); +``` + +**Note:** Tool support varies by model. Models like DeepSeek R1, Llama 3.1, and Qwen 2.5 generally have good tool calling support. + +## Model Options + +Nebius supports various provider-specific options compatible with OpenAI's chat completions API: + +```typescript +const stream = chat({ + adapter: nebiusText("deepseek-ai/DeepSeek-R1-0528"), + messages, + modelOptions: { + temperature: 0.7, + top_p: 0.9, + max_tokens: 1000, + presence_penalty: 0.5, + frequency_penalty: 0.5, + stop: ["END"], + }, +}); +``` + +### Available Options + +```typescript +modelOptions: { + // Sampling + temperature: 0.7, // 0-2, controls randomness + top_p: 0.9, // 0-1, nucleus sampling + + // Generation + max_tokens: 1000, // Maximum tokens to generate + stop: ["END"], // Stop sequences (string or array) + + // Penalties + presence_penalty: 0.5, // -2.0 to 2.0 + frequency_penalty: 0.5, // -2.0 to 2.0 + + // Advanced + logit_bias: {}, // Logit bias map + user: "user-123", // User identifier +} +``` + +## Summarization + +Summarize long text content: + +```typescript +import { summarize } from "@tanstack/ai"; +import { nebiusSummarize } from "@tanstack/ai-nebius"; + +const result = await summarize({ + adapter: nebiusSummarize("deepseek-ai/DeepSeek-R1-0528"), + text: "Your long text to summarize...", + maxLength: 100, + style: "concise", // "concise" | "bullet-points" | "paragraph" +}); + +console.log(result.summary); +``` + +## Getting Started with Nebius Token Factory + +### 1. Create an Account + +Visit [Nebius Token Factory](https://tokenfactory.nebius.com) and sign up using your Google or GitHub account. + +### 2. Get an API Key + +- Navigate to the **API keys** section in your dashboard +- Click on **Create API key** +- Provide a name for the key +- Save the generated API key securely + +### 3. Set Environment Variable + +```bash +NEBIUS_API_KEY=your-api-key-here +``` + +## Environment Variables + +Optionally set the API key in environment variables: + +```bash +NEBIUS_API_KEY=your-api-key-here +``` + +The adapter will automatically detect `NEBIUS_API_KEY` from: +- `process.env` (Node.js) +- `window.env` (Browser with injected env) + +## API Reference + +### `nebiusText(model, config?)` + +Creates a Nebius text/chat adapter using environment variables. + +**Parameters:** +- `model` - The model name (e.g., 'deepseek-ai/DeepSeek-R1-0528') +- `config.apiKey?` - Optional API key (if not provided, uses NEBIUS_API_KEY env var) +- `config.baseURL?` - Optional base URL (defaults to Nebius endpoint) + +**Returns:** A Nebius text adapter instance. + +### `createNebiusChat(model, apiKey, config?)` + +Creates a Nebius text/chat adapter with an explicit API key. + +**Parameters:** +- `model` - The model name +- `apiKey` - Your Nebius API key +- `config.baseURL?` - Optional base URL + +**Returns:** A Nebius text adapter instance. + +### `nebiusSummarize(model, config?)` + +Creates a Nebius summarization adapter using environment variables. + +**Returns:** A Nebius summarize adapter instance. + +### `createNebiusSummarize(model, apiKey, config?)` + +Creates a Nebius summarization adapter with an explicit API key. + +**Returns:** A Nebius summarize adapter instance. + +## Benefits of Nebius Token Factory + +- ✅ **Multiple Models** - Access to DeepSeek, Llama, Qwen, and more +- ✅ **OpenAI-Compatible** - Familiar API interface +- ✅ **Cost-Effective** - Competitive pricing for inference +- ✅ **Scalable** - Enterprise-grade infrastructure +- ✅ **Easy Integration** - Drop-in replacement for OpenAI-compatible workflows + +## Limitations + +- **Image Generation**: Nebius Token Factory does not support image generation. Use OpenAI or Gemini for image generation. +- **Model Availability**: Available models may vary. Check the [Nebius documentation](https://docs.tokenfactory.nebius.com/ai-models-inference/overview) for current offerings. + +## Next Steps + +- [Getting Started](../getting-started/quick-start) - Learn the basics +- [Tools Guide](../guides/tools) - Learn about tools +- [Other Adapters](./openai) - Explore other providers + diff --git a/docs/config.json b/docs/config.json index 375489c0..1eb05f83 100644 --- a/docs/config.json +++ b/docs/config.json @@ -139,6 +139,10 @@ { "label": "Ollama", "to": "adapters/ollama" + }, + { + "label": "Nebius Token Factory", + "to": "adapters/nebius" } ] }, diff --git a/knip.json b/knip.json index 8f2913dc..04f475c9 100644 --- a/knip.json +++ b/knip.json @@ -27,6 +27,14 @@ "packages/typescript/ai-openai": { "ignore": ["src/tools/**"] }, + "packages/typescript/ai-solid": { + "tsdown": false, + "ignore": ["tsdown.config.ts"] + }, + "packages/typescript/ai-vue": { + "tsdown": false, + "ignore": ["tsdown.config.ts"] + }, "packages/typescript/ai-vue-ui": { "ignoreDependencies": [ "@crazydos/vue-markdown", diff --git a/package.json b/package.json index 27baecb6..3f4b5321 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "format": "prettier --experimental-cli --ignore-unknown '**/*' --write", "generate-docs": "node scripts/generate-docs.ts && pnpm run copy:readme", "sync-docs-config": "node scripts/sync-docs-config.ts", - "copy:readme": "cp README.md packages/typescript/ai/README.md && cp README.md packages/typescript/ai-devtools/README.md && cp README.md packages/typescript/ai-client/README.md && cp README.md packages/typescript/ai-gemini/README.md && cp README.md packages/typescript/ai-ollama/README.md && cp README.md packages/typescript/ai-openai/README.md && cp README.md packages/typescript/ai-react/README.md && cp README.md packages/typescript/ai-react-ui/README.md && cp README.md packages/typescript/react-ai-devtools/README.md && cp README.md packages/typescript/solid-ai-devtools/README.md", + "copy:readme": "cp README.md packages/typescript/ai/README.md && cp README.md packages/typescript/ai-devtools/README.md && cp README.md packages/typescript/ai-client/README.md && cp README.md packages/typescript/ai-gemini/README.md && cp README.md packages/typescript/ai-ollama/README.md && cp README.md packages/typescript/ai-openai/README.md && cp README.md packages/typescript/ai-react/README.md && cp README.md packages/typescript/ai-react-ui/README.md && cp README.md packages/typescript/react-ai-devtools/README.md && cp README.md packages/typescript/solid-ai-devtools/README.md ", "changeset": "changeset", "changeset:publish": "changeset publish", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm format" diff --git a/packages/typescript/ai-nebius/CHANGELOG.md b/packages/typescript/ai-nebius/CHANGELOG.md new file mode 100644 index 00000000..bba4f305 --- /dev/null +++ b/packages/typescript/ai-nebius/CHANGELOG.md @@ -0,0 +1,12 @@ +# @tanstack/ai-nebius + +## 0.2.0 + +### Minor Changes + +- Initial release of Nebius Token Factory adapter for TanStack AI + - Text/chat adapter with streaming support + - Summarization adapter + - Tool/function calling support + - OpenAI-compatible API using `https://api.tokenfactory.nebius.com/v1/` + - Support for DeepSeek, Llama, Qwen models diff --git a/packages/typescript/ai-nebius/README.md b/packages/typescript/ai-nebius/README.md new file mode 100644 index 00000000..7d6922a9 --- /dev/null +++ b/packages/typescript/ai-nebius/README.md @@ -0,0 +1,25 @@ +# @tanstack/ai-nebius + +Nebius Token Factory adapter for TanStack AI. + +## Installation + +```bash +npm install @tanstack/ai-nebius +``` + +## Usage + +```typescript +import { chat } from '@tanstack/ai' +import { nebiusText } from '@tanstack/ai-nebius' + +const stream = chat({ + adapter: nebiusText('deepseek-ai/DeepSeek-R1-0528'), + messages: [{ role: 'user', content: 'Hello!' }], +}) +``` + +## Documentation + +See the [Nebius Token Factory adapter documentation](../../../docs/adapters/nebius.md) for full details. diff --git a/packages/typescript/ai-nebius/package.json b/packages/typescript/ai-nebius/package.json new file mode 100644 index 00000000..6c417b12 --- /dev/null +++ b/packages/typescript/ai-nebius/package.json @@ -0,0 +1,56 @@ +{ + "name": "@tanstack/ai-nebius", + "version": "0.2.0", + "description": "Nebius Token Factory adapter for TanStack AI", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-nebius" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "nebius", + "token-factory", + "deepseek", + "llama", + "qwen", + "tanstack", + "adapter" + ], + "dependencies": { + "openai": "^6.9.1" + }, + "devDependencies": { + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" + } +} diff --git a/packages/typescript/ai-nebius/src/adapters/summarize.ts b/packages/typescript/ai-nebius/src/adapters/summarize.ts new file mode 100644 index 00000000..ece6dd7d --- /dev/null +++ b/packages/typescript/ai-nebius/src/adapters/summarize.ts @@ -0,0 +1,187 @@ +import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { NebiusTextAdapter } from './text' +import type { + StreamChunk, + SummarizationOptions, + SummarizationResult, +} from '@tanstack/ai' + +/** + * Nebius models suitable for summarization + * Note: Nebius models are dynamically available, this is a common subset + */ +export const NebiusSummarizeModels = [ + 'deepseek-ai/DeepSeek-R1-0528', + 'deepseek-ai/DeepSeek-V3-0324', + 'meta-llama/Meta-Llama-3.1-70B-Instruct', + 'meta-llama/Meta-Llama-3.1-8B-Instruct', + 'Qwen/Qwen2.5-72B-Instruct', + 'Qwen/Qwen2.5-7B-Instruct', +] as const + +export type NebiusSummarizeModel = + | (typeof NebiusSummarizeModels)[number] + | (string & {}) + +/** + * Nebius-specific provider options for summarization + */ +export interface NebiusSummarizeProviderOptions { + /** Temperature for response generation (0-2) */ + temperature?: number + /** Maximum tokens in the response */ + maxTokens?: number +} + +export interface NebiusSummarizeAdapterOptions { + apiKey?: string + baseURL?: string +} + +/** + * Nebius Summarize Adapter + * + * A thin wrapper around the text adapter that adds summarization-specific prompting. + * Delegates all API calls to the NebiusTextAdapter. + */ +export class NebiusSummarizeAdapter< + TModel extends NebiusSummarizeModel, +> extends BaseSummarizeAdapter { + readonly kind = 'summarize' as const + readonly name = 'nebius' as const + + private textAdapter: NebiusTextAdapter + + constructor( + config: NebiusSummarizeAdapterOptions | undefined, + model: TModel, + ) { + super({}, model) + this.textAdapter = new NebiusTextAdapter(config, model) + } + + async summarize(options: SummarizationOptions): Promise { + const systemPrompt = this.buildSummarizationPrompt(options) + + // Use the text adapter's streaming and collect the result + let summary = '' + let id = '' + let model = options.model + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + + for await (const chunk of this.textAdapter.chatStream({ + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + })) { + if (chunk.type === 'content') { + summary = chunk.content + id = chunk.id + model = chunk.model + } + if (chunk.type === 'done' && chunk.usage) { + usage = chunk.usage + } + } + + return { id, model, summary, usage } + } + + async *summarizeStream( + options: SummarizationOptions, + ): AsyncIterable { + const systemPrompt = this.buildSummarizationPrompt(options) + + // Delegate directly to the text adapter's streaming + yield* this.textAdapter.chatStream({ + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + }) + } + + private buildSummarizationPrompt(options: SummarizationOptions): string { + let prompt = 'You are a professional summarizer. ' + + switch (options.style) { + case 'bullet-points': + prompt += 'Provide a summary in bullet point format. ' + break + case 'paragraph': + prompt += 'Provide a summary in paragraph format. ' + break + case 'concise': + prompt += 'Provide a very concise summary in 1-2 sentences. ' + break + default: + prompt += 'Provide a clear and concise summary. ' + } + + if (options.focus && options.focus.length > 0) { + prompt += `Focus on the following aspects: ${options.focus.join(', ')}. ` + } + + if (options.maxLength) { + prompt += `Keep the summary under ${options.maxLength} tokens. ` + } + + return prompt + } +} + +/** + * Creates a Nebius summarize adapter with explicit API key. + * Type resolution happens here at the call site. + * + * @param model - The model name (e.g., 'deepseek-ai/DeepSeek-R1-0528') + * @param apiKey - Your Nebius API key + * @param config - Optional additional configuration + * @returns Configured Nebius summarize adapter instance with resolved types + * + * @example + * ```typescript + * const adapter = createNebiusSummarize('deepseek-ai/DeepSeek-R1-0528', "your-api-key"); + * ``` + */ +export function createNebiusSummarize( + model: TModel, + apiKey: string, + config?: Omit, +): NebiusSummarizeAdapter { + return new NebiusSummarizeAdapter({ apiKey, ...config }, model) +} + +/** + * Creates a Nebius summarize adapter with automatic API key detection from environment variables. + * Type resolution happens here at the call site. + * + * Looks for `NEBIUS_API_KEY` in: + * - `process.env` (Node.js) + * - `window.env` (Browser with injected env) + * + * @param model - The model name (e.g., 'deepseek-ai/DeepSeek-R1-0528') + * @param config - Optional configuration (excluding apiKey which is auto-detected) + * @returns Configured Nebius summarize adapter instance with resolved types + * @throws Error if NEBIUS_API_KEY is not found in environment + * + * @example + * ```typescript + * // Automatically uses NEBIUS_API_KEY from environment + * const adapter = nebiusSummarize('deepseek-ai/DeepSeek-R1-0528'); + * + * await summarize({ + * adapter, + * text: "Long article text..." + * }); + * ``` + */ +export function nebiusSummarize( + model: TModel, + config?: Omit, +): NebiusSummarizeAdapter { + return new NebiusSummarizeAdapter(config, model) +} diff --git a/packages/typescript/ai-nebius/src/adapters/text.ts b/packages/typescript/ai-nebius/src/adapters/text.ts new file mode 100644 index 00000000..bd168324 --- /dev/null +++ b/packages/typescript/ai-nebius/src/adapters/text.ts @@ -0,0 +1,494 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { + createNebiusClient, + generateId, + getNebiusApiKeyFromEnv, +} from '../utils' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import type OpenAI_SDK from 'openai' +import type { StreamChunk, TextOptions, Tool } from '@tanstack/ai' +import type { + NebiusImageMetadata, + NebiusMessageMetadataByModality, +} from '../message-types' + +/** + * Nebius Token Factory text models + * Note: Nebius models are dynamically available, this is a common subset + */ +export const NebiusTextModels = [ + // DeepSeek models + 'deepseek-ai/DeepSeek-R1-0528', + 'deepseek-ai/DeepSeek-V3-0324', + // Llama models + 'meta-llama/Meta-Llama-3.1-70B-Instruct', + 'meta-llama/Meta-Llama-3.1-8B-Instruct', + 'meta-llama/Meta-Llama-3.1-405B-Instruct', + // Qwen models + 'Qwen/Qwen2.5-72B-Instruct', + 'Qwen/Qwen2.5-7B-Instruct', + 'Qwen/Qwen2.5-32B-Instruct', +] as const + +export type NebiusTextModel = (typeof NebiusTextModels)[number] | (string & {}) + +/** + * Nebius-specific provider options + * Compatible with OpenAI chat completions API options + */ +export interface NebiusTextProviderOptions { + /** Temperature for sampling (0-2) */ + temperature?: number + /** Top-p sampling (0-1) */ + top_p?: number + /** Maximum tokens to generate */ + max_tokens?: number + /** Stop sequences */ + stop?: string | Array + /** Presence penalty (-2.0 to 2.0) */ + presence_penalty?: number + /** Frequency penalty (-2.0 to 2.0) */ + frequency_penalty?: number + /** Logit bias */ + logit_bias?: Record + /** User identifier */ + user?: string +} + +export interface NebiusTextAdapterOptions { + apiKey?: string + baseURL?: string +} + +/** + * Default input modalities for Nebius models + */ +type NebiusInputModalities = readonly ['text', 'image'] + +/** + * Nebius Text/Chat Adapter + * A tree-shakeable chat adapter for Nebius Token Factory + * + * Note: Nebius supports any model name as a string since models are dynamically available. + * The predefined NebiusTextModels are common models but any string is accepted. + */ +export class NebiusTextAdapter extends BaseTextAdapter< + TModel, + NebiusTextProviderOptions, + NebiusInputModalities, + NebiusMessageMetadataByModality +> { + readonly kind = 'text' as const + readonly name = 'nebius' as const + + private client: OpenAI_SDK + + constructor(config: NebiusTextAdapterOptions | undefined, model: TModel) { + super({}, model) + const apiKey = config?.apiKey || getNebiusApiKeyFromEnv() + this.client = createNebiusClient({ + apiKey, + baseURL: config?.baseURL, + }) + } + + async *chatStream(options: TextOptions): AsyncIterable { + const requestParams = this.mapTextOptionsToNebius(options) + + try { + const stream = await this.client.chat.completions.create({ + ...requestParams, + stream: true, + }) + + yield* this.processNebiusStreamChunks(stream, options) + } catch (error: unknown) { + const err = error as Error + console.error('>>> chatStream: Fatal error during response creation <<<') + console.error('>>> Error message:', err.message) + console.error('>>> Error stack:', err.stack) + throw error + } + } + + /** + * Generate structured output using Nebius's JSON mode. + * Uses response_format: { type: 'json_object' } for basic JSON mode. + * Note: For stricter schema validation, include the schema in the system prompt. + */ + async structuredOutput( + options: StructuredOutputOptions, + ): Promise> { + const { chatOptions, outputSchema } = options + + const requestParams = this.mapTextOptionsToNebius(chatOptions) + + try { + // Add schema to system prompt for better adherence + const systemPromptWithSchema = `You must respond with valid JSON matching this schema: ${JSON.stringify(outputSchema, null, 2)}` + + // Prepend system message with schema + const messagesWithSchema: Array = + [ + { role: 'system', content: systemPromptWithSchema }, + ...(Array.isArray(requestParams.messages) + ? requestParams.messages + : []), + ] + + // Make non-streaming request with JSON format + const response = await this.client.chat.completions.create({ + ...requestParams, + messages: messagesWithSchema, + stream: false, + response_format: { + type: 'json_object', + }, + }) + + const rawText = response.choices[0]!.message.content || '' + + // Parse the JSON response + let parsed: unknown + try { + parsed = JSON.parse(rawText) + } catch { + throw new Error( + `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`, + ) + } + + return { + data: parsed, + rawText, + } + } catch (error: unknown) { + const err = error as Error + throw new Error( + `Structured output generation failed: ${err.message || 'Unknown error occurred'}`, + ) + } + } + + private async *processNebiusStreamChunks( + stream: AsyncIterable, + options: TextOptions, + ): AsyncIterable { + let accumulatedContent = '' + const timestamp = Date.now() + const responseId = generateId('msg') + let model = options.model + let hasEmittedToolCalls = false + const toolCallMetadata = new Map< + string, + { index: number; name: string; arguments: string } + >() + + try { + for await (const chunk of stream) { + // Update model from chunk if available + if (chunk.model) { + model = chunk.model + } + + // Handle content deltas + const delta = chunk.choices[0]?.delta + if (delta?.content) { + accumulatedContent += delta.content + yield { + type: 'content', + id: responseId, + model, + timestamp, + delta: delta.content, + content: accumulatedContent, + role: 'assistant', + } + } + + // Handle tool call deltas + if (delta?.tool_calls) { + for (const toolCallDelta of delta.tool_calls) { + const toolCallId = toolCallDelta.id + if (!toolCallId) continue + + if (!toolCallMetadata.has(toolCallId)) { + toolCallMetadata.set(toolCallId, { + index: toolCallDelta.index || 0, + name: toolCallDelta.function?.name || '', + arguments: toolCallDelta.function?.arguments || '', + }) + } else { + const metadata = toolCallMetadata.get(toolCallId)! + if (toolCallDelta.function?.arguments) { + metadata.arguments += toolCallDelta.function.arguments + } + } + } + } + + // Handle finish reason + const finishReason = chunk.choices[0]?.finish_reason + if (finishReason) { + // Emit any pending tool calls + if (toolCallMetadata.size > 0) { + for (const [toolCallId, metadata] of toolCallMetadata.entries()) { + yield { + type: 'tool_call', + id: responseId, + model, + timestamp, + index: metadata.index, + toolCall: { + id: toolCallId, + type: 'function', + function: { + name: metadata.name, + arguments: metadata.arguments, + }, + }, + } + hasEmittedToolCalls = true + } + } + + yield { + type: 'done', + id: responseId, + model, + timestamp, + finishReason: + finishReason === 'tool_calls' || hasEmittedToolCalls + ? 'tool_calls' + : finishReason === 'length' + ? 'length' + : 'stop', + usage: chunk.usage + ? { + promptTokens: chunk.usage.prompt_tokens || 0, + completionTokens: chunk.usage.completion_tokens || 0, + totalTokens: chunk.usage.total_tokens || 0, + } + : undefined, + } + } + } + } catch (error: unknown) { + const err = error as Error + yield { + type: 'error', + id: responseId, + model, + timestamp, + error: { + message: err.message || 'Unknown error occurred', + }, + } + } + } + + private convertToolsToNebiusFormat( + tools?: Array, + ): Array | undefined { + if (!tools || tools.length === 0) { + return undefined + } + + return tools.map( + (tool) => + ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as OpenAI_SDK.FunctionParameters, + }, + }) satisfies OpenAI_SDK.Chat.Completions.ChatCompletionTool, + ) + } + + private formatMessages( + messages: TextOptions['messages'], + ): Array { + return messages.map((msg) => { + switch (msg.role) { + case 'tool': + return { + role: 'tool', + tool_call_id: msg.toolCallId || '', + content: + typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content), + } + + case 'assistant': { + const assistantContent: string | null = + typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content + .filter((p) => p.type === 'text') + .map((p) => p.content) + .join('') + : null + + const result: OpenAI_SDK.Chat.Completions.ChatCompletionAssistantMessageParam = + { + role: 'assistant', + content: assistantContent || '', + } + + // Add tool calls if present + if (msg.toolCalls && msg.toolCalls.length > 0) { + result.tool_calls = msg.toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: 'function', + function: { + name: toolCall.function.name, + arguments: + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments), + }, + })) + } + + return result + } + + case 'user': + default: { + const contentParts: Array< + | OpenAI_SDK.Chat.Completions.ChatCompletionContentPartText + | OpenAI_SDK.Chat.Completions.ChatCompletionContentPartImage + > = [] + + if (typeof msg.content === 'string') { + contentParts.push({ + type: 'text', + text: msg.content, + }) + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text') { + contentParts.push({ + type: 'text', + text: part.content, + }) + } else if (part.type === 'image') { + const imageMetadata = part.metadata as + | NebiusImageMetadata + | undefined + contentParts.push({ + type: 'image_url', + image_url: { + url: part.source.value, + detail: imageMetadata?.detail || 'auto', + }, + }) + } + } + } + + // If single text part, use string; otherwise use array + const firstPart = contentParts[0] + const useSimpleString = + contentParts.length === 1 && + firstPart !== undefined && + firstPart.type === 'text' + + return { + role: 'user', + content: useSimpleString ? firstPart.text : contentParts, + } + } + } + }) + } + + private mapTextOptionsToNebius( + options: TextOptions, + ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParams { + const modelOptions = options.modelOptions as + | NebiusTextProviderOptions + | undefined + + // Build messages array + const formattedMessages = this.formatMessages(options.messages) + const messages: Array = + options.systemPrompts && options.systemPrompts.length > 0 + ? [ + { + role: 'system', + content: options.systemPrompts.join('\n'), + }, + ...formattedMessages, + ] + : formattedMessages + + return { + model: options.model, + messages, + temperature: options.temperature ?? modelOptions?.temperature, + top_p: options.topP ?? modelOptions?.top_p, + max_tokens: options.maxTokens ?? modelOptions?.max_tokens, + stop: modelOptions?.stop, + presence_penalty: modelOptions?.presence_penalty, + frequency_penalty: modelOptions?.frequency_penalty, + logit_bias: modelOptions?.logit_bias, + user: modelOptions?.user, + tools: this.convertToolsToNebiusFormat(options.tools), + } + } +} + +/** + * Creates a Nebius chat adapter with explicit API key. + * Type resolution happens here at the call site. + */ +export function createNebiusChat( + model: TModel, + apiKey: string, + config?: Omit, +): NebiusTextAdapter { + return new NebiusTextAdapter({ apiKey, ...config }, model) +} + +/** + * Creates a Nebius text adapter with automatic API key detection from environment variables. + * Type resolution happens here at the call site. + * + * Looks for `NEBIUS_API_KEY` in: + * - `process.env` (Node.js) + * - `window.env` (Browser with injected env) + * + * @param model - The model name (e.g., 'deepseek-ai/DeepSeek-R1-0528', 'meta-llama/Meta-Llama-3.1-70B-Instruct') + * @param config - Optional configuration (excluding apiKey which is auto-detected) + * @returns Configured Nebius text adapter instance with resolved types + * @throws Error if NEBIUS_API_KEY is not found in environment + * + * @example + * ```typescript + * // Automatically uses NEBIUS_API_KEY from environment + * const adapter = nebiusText('deepseek-ai/DeepSeek-R1-0528'); + * + * const stream = chat({ + * adapter, + * messages: [{ role: "user", content: "Hello!" }] + * }); + * ``` + */ +export function nebiusText( + model: TModel, + config?: Omit, +): NebiusTextAdapter { + return new NebiusTextAdapter(config, model) +} diff --git a/packages/typescript/ai-nebius/src/index.ts b/packages/typescript/ai-nebius/src/index.ts new file mode 100644 index 00000000..afecd1d1 --- /dev/null +++ b/packages/typescript/ai-nebius/src/index.ts @@ -0,0 +1,37 @@ +// =========================== +// New tree-shakeable adapters +// =========================== + +// Text/Chat adapter +export { + NebiusTextAdapter, + NebiusTextModels, + createNebiusChat, + nebiusText, + type NebiusTextAdapterOptions, + type NebiusTextModel, + type NebiusTextProviderOptions, +} from './adapters/text' + +// Summarize adapter +export { + NebiusSummarizeAdapter, + NebiusSummarizeModels, + createNebiusSummarize, + nebiusSummarize, + type NebiusSummarizeAdapterOptions, + type NebiusSummarizeModel, + type NebiusSummarizeProviderOptions, +} from './adapters/summarize' + +// =========================== +// Type Exports +// =========================== + +export type { + NebiusImageMetadata, + NebiusAudioMetadata, + NebiusVideoMetadata, + NebiusDocumentMetadata, + NebiusMessageMetadataByModality, +} from './message-types' diff --git a/packages/typescript/ai-nebius/src/message-types.ts b/packages/typescript/ai-nebius/src/message-types.ts new file mode 100644 index 00000000..37689dd7 --- /dev/null +++ b/packages/typescript/ai-nebius/src/message-types.ts @@ -0,0 +1,68 @@ +/** + * Nebius Token Factory-specific metadata types for multimodal content parts. + * These types extend the base ContentPart metadata with Nebius-specific options. + * + * Nebius Token Factory uses OpenAI-compatible API, so metadata follows OpenAI conventions. + * @see https://docs.tokenfactory.nebius.com + */ + +/** + * Metadata for Nebius image content parts. + * Controls how the model processes and analyzes images. + */ +export interface NebiusImageMetadata { + /** + * Controls how the model processes the image. + * - 'auto': Let the model decide based on image size and content + * - 'low': Use low resolution processing (faster, cheaper, less detail) + * - 'high': Use high resolution processing (slower, more expensive, more detail) + * + * @default 'auto' + */ + detail?: 'auto' | 'low' | 'high' +} + +/** + * Metadata for Nebius audio content parts. + * Placeholder for future audio support. + */ +export interface NebiusAudioMetadata { + /** + * The format of the audio. + */ + format?: 'mp3' | 'wav' | 'flac' | 'ogg' | 'webm' | 'aac' +} + +/** + * Metadata for Nebius video content parts. + * Placeholder for future video support. + */ +export interface NebiusVideoMetadata { + /** + * The format of the video. + */ + format?: 'mp4' | 'webm' +} + +/** + * Metadata for Nebius document content parts. + * Placeholder for future document support. + */ +export interface NebiusDocumentMetadata { + /** + * The MIME type of the document. + */ + mediaType?: 'application/pdf' +} + +/** + * Map of modality types to their Nebius-specific metadata types. + * Used for type inference when constructing multimodal messages. + */ +export interface NebiusMessageMetadataByModality { + text: unknown + image: NebiusImageMetadata + audio: NebiusAudioMetadata + video: NebiusVideoMetadata + document: NebiusDocumentMetadata +} diff --git a/packages/typescript/ai-nebius/src/utils/client.ts b/packages/typescript/ai-nebius/src/utils/client.ts new file mode 100644 index 00000000..52407ff4 --- /dev/null +++ b/packages/typescript/ai-nebius/src/utils/client.ts @@ -0,0 +1,45 @@ +import OpenAI_SDK from 'openai' + +export interface NebiusClientConfig { + apiKey: string + baseURL?: string +} + +/** + * Creates an OpenAI SDK client instance configured for Nebius Token Factory + */ +export function createNebiusClient(config: NebiusClientConfig): OpenAI_SDK { + return new OpenAI_SDK({ + apiKey: config.apiKey, + baseURL: config.baseURL || 'https://api.tokenfactory.nebius.com/v1/', + }) +} + +/** + * Gets Nebius API key from environment variables + * @throws Error if NEBIUS_API_KEY is not found + */ +export function getNebiusApiKeyFromEnv(): string { + const env = + typeof globalThis !== 'undefined' && (globalThis as any).window?.env + ? (globalThis as any).window.env + : typeof process !== 'undefined' + ? process.env + : undefined + const key = env?.NEBIUS_API_KEY + + if (!key) { + throw new Error( + 'NEBIUS_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', + ) + } + + return key +} + +/** + * Generates a unique ID with a prefix + */ +export function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` +} diff --git a/packages/typescript/ai-nebius/src/utils/index.ts b/packages/typescript/ai-nebius/src/utils/index.ts new file mode 100644 index 00000000..89728a93 --- /dev/null +++ b/packages/typescript/ai-nebius/src/utils/index.ts @@ -0,0 +1,6 @@ +export { + createNebiusClient, + getNebiusApiKeyFromEnv, + generateId, + type NebiusClientConfig, +} from './client' diff --git a/packages/typescript/ai-nebius/tests/nebius-adapter.test.ts b/packages/typescript/ai-nebius/tests/nebius-adapter.test.ts new file mode 100644 index 00000000..5303290e --- /dev/null +++ b/packages/typescript/ai-nebius/tests/nebius-adapter.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { chat, type Tool, type StreamChunk } from '@tanstack/ai' +import { NebiusTextAdapter } from '../src/adapters/text' +import type { NebiusTextProviderOptions } from '../src/adapters/text' + +const createAdapter = (model: TModel) => + new NebiusTextAdapter({ apiKey: 'test-key' }, model) + +const toolArguments = JSON.stringify({ location: 'Berlin' }) + +const weatherTool: Tool = { + name: 'lookup_weather', + description: 'Return the forecast for a location', +} + +function createMockChatCompletionsStream( + chunks: Array>, +): AsyncIterable> { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk + } + }, + } +} + +describe('Nebius adapter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('option mapping', () => { + it('maps options into the Chat Completions API payload', async () => { + // Mock the Chat Completions API stream format + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + object: 'chat.completion.chunk', + created: 1234567890, + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'It is sunny' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + object: 'chat.completion.chunk', + created: 1234567890, + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }, + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('deepseek-ai/DeepSeek-R1-0528') + // Replace the internal OpenAI SDK client with our mock + ;(adapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const modelOptions: NebiusTextProviderOptions = { + presence_penalty: 0.5, + frequency_penalty: 0.3, + } + + const chunks: StreamChunk[] = [] + for await (const chunk of chat({ + adapter, + messages: [ + { role: 'system', content: 'Stay concise' }, + { role: 'user', content: 'How is the weather?' }, + ], + tools: [weatherTool], + temperature: 0.25, + topP: 0.6, + maxTokens: 1024, + modelOptions, + })) { + chunks.push(chunk) + } + + expect(chatCompletionsCreate).toHaveBeenCalledTimes(1) + const [payload] = chatCompletionsCreate.mock.calls[0] + + // Verify Chat Completions API format + expect(payload).toMatchObject({ + model: 'deepseek-ai/DeepSeek-R1-0528', + temperature: 0.25, + top_p: 0.6, + max_tokens: 1024, + stream: true, + presence_penalty: 0.5, + frequency_penalty: 0.3, + }) + + // Verify messages are included + expect(payload.messages).toBeDefined() + expect(Array.isArray(payload.messages)).toBe(true) + + // Verify tools are included + expect(payload.tools).toBeDefined() + expect(Array.isArray(payload.tools)).toBe(true) + expect(payload.tools.length).toBeGreaterThan(0) + }) + }) + + describe('streaming', () => { + it('handles content streaming correctly', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'Hello' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: { content: ' world!' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('meta-llama/Meta-Llama-3.1-70B-Instruct') + ;(adapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const chunks: StreamChunk[] = [] + for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'Say hello!' }], + })) { + chunks.push(chunk) + } + + // Should have content chunks plus done chunk + expect(chunks.length).toBeGreaterThanOrEqual(2) + + // Find content chunks + const contentChunks = chunks.filter((c) => c.type === 'content') + expect(contentChunks.length).toBe(2) + + // Verify accumulated content + const lastContentChunk = contentChunks[contentChunks.length - 1] + if (lastContentChunk.type === 'content') { + expect(lastContentChunk.content).toBe('Hello world!') + } + + // Verify done chunk + const doneChunk = chunks.find((c) => c.type === 'done') + expect(doneChunk).toBeDefined() + if (doneChunk?.type === 'done') { + expect(doneChunk.finishReason).toBe('stop') + } + }) + }) + + describe('tool calls', () => { + it('handles tool call streaming correctly', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'Qwen/Qwen2.5-72B-Instruct', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_abc123', + index: 0, + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"loc', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'Qwen/Qwen2.5-72B-Instruct', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + id: 'call_abc123', + index: 0, + function: { + arguments: 'ation":"Berlin"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'Qwen/Qwen2.5-72B-Instruct', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('Qwen/Qwen2.5-72B-Instruct') + ;(adapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const chunks: StreamChunk[] = [] + for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'What is the weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + // Find tool call chunk + const toolCallChunk = chunks.find((c) => c.type === 'tool_call') + expect(toolCallChunk).toBeDefined() + if (toolCallChunk?.type === 'tool_call') { + expect(toolCallChunk.toolCall.function.name).toBe('lookup_weather') + expect(toolCallChunk.toolCall.function.arguments).toContain('Berlin') + } + + // Verify done chunk with tool_calls finish reason + const doneChunk = chunks.find((c) => c.type === 'done') + expect(doneChunk).toBeDefined() + if (doneChunk?.type === 'done') { + expect(doneChunk.finishReason).toBe('tool_calls') + } + }) + }) + + describe('message formatting', () => { + it('handles conversation with tool results', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'The temperature is 72°F' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('deepseek-ai/DeepSeek-R1-0528') + ;(adapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const chunks: StreamChunk[] = [] + for await (const chunk of chat({ + adapter, + messages: [ + { role: 'user', content: 'What is the weather in Berlin?' }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_weather', + type: 'function', + function: { name: 'lookup_weather', arguments: toolArguments }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_weather', content: '{"temp":72}' }, + ], + })) { + chunks.push(chunk) + } + + expect(chatCompletionsCreate).toHaveBeenCalledTimes(1) + const [payload] = chatCompletionsCreate.mock.calls[0] + + // Verify messages include user, assistant with tool calls, and tool result + expect(payload.messages.length).toBe(3) + + // Verify tool message format + const toolMessage = payload.messages.find( + (m: Record) => m.role === 'tool', + ) + expect(toolMessage).toBeDefined() + expect(toolMessage.tool_call_id).toBe('call_weather') + expect(toolMessage.content).toBe('{"temp":72}') + }) + }) + + describe('multimodal content', () => { + it('handles image content in messages', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'I see a cat.' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('meta-llama/Meta-Llama-3.1-70B-Instruct') + ;(adapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const chunks: StreamChunk[] = [] + for await (const chunk of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { type: 'text', content: 'What is in this image?' }, + { + type: 'image', + source: { + type: 'url', + value: 'https://example.com/cat.jpg', + }, + }, + ], + }, + ], + })) { + chunks.push(chunk) + } + + expect(chatCompletionsCreate).toHaveBeenCalledTimes(1) + const [payload] = chatCompletionsCreate.mock.calls[0] + + // Verify multimodal content format + const userMessage = payload.messages[0] + expect(userMessage.role).toBe('user') + expect(Array.isArray(userMessage.content)).toBe(true) + expect(userMessage.content.length).toBe(2) + + // Verify text part + expect(userMessage.content[0].type).toBe('text') + expect(userMessage.content[0].text).toBe('What is in this image?') + + // Verify image part + expect(userMessage.content[1].type).toBe('image_url') + expect(userMessage.content[1].image_url.url).toBe( + 'https://example.com/cat.jpg', + ) + }) + }) +}) + +describe('Nebius client configuration', () => { + it('uses correct default base URL', () => { + const adapter = new NebiusTextAdapter( + { apiKey: 'test-key' }, + 'deepseek-ai/DeepSeek-R1-0528', + ) + + // The client should be configured with Nebius Token Factory URL + const client = (adapter as any).client + expect(client).toBeDefined() + }) + + it('allows custom base URL', () => { + const adapter = new NebiusTextAdapter( + { apiKey: 'test-key', baseURL: 'https://custom.api.com/v1/' }, + 'deepseek-ai/DeepSeek-R1-0528', + ) + + const client = (adapter as any).client + expect(client).toBeDefined() + }) +}) diff --git a/packages/typescript/ai-nebius/tests/nebius-summarize.test.ts b/packages/typescript/ai-nebius/tests/nebius-summarize.test.ts new file mode 100644 index 00000000..7b400721 --- /dev/null +++ b/packages/typescript/ai-nebius/tests/nebius-summarize.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NebiusSummarizeAdapter } from '../src/adapters/summarize' +import type { StreamChunk } from '@tanstack/ai' + +const createAdapter = (model: TModel) => + new NebiusSummarizeAdapter({ apiKey: 'test-key' }, model) + +function createMockChatCompletionsStream( + chunks: Array>, +): AsyncIterable> { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk + } + }, + } +} + +describe('Nebius Summarize Adapter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('summarize', () => { + it('generates a summary using the text adapter', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'This is a summary.' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 5, + total_tokens: 105, + }, + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('deepseek-ai/DeepSeek-R1-0528') + // Mock the internal text adapter's client + const textAdapter = (adapter as any).textAdapter + ;(textAdapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const result = await adapter.summarize({ + model: 'deepseek-ai/DeepSeek-R1-0528', + text: 'This is a long article about AI and machine learning. It discusses various topics including neural networks, deep learning, and natural language processing.', + style: 'concise', + maxLength: 100, + }) + + expect(result.summary).toBe('This is a summary.') + expect(result.model).toBe('deepseek-ai/DeepSeek-R1-0528') + }) + + it('applies correct summarization prompt for bullet-points style', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '• Point 1\n• Point 2' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('meta-llama/Meta-Llama-3.1-70B-Instruct') + const textAdapter = (adapter as any).textAdapter + ;(textAdapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + await adapter.summarize({ + model: 'meta-llama/Meta-Llama-3.1-70B-Instruct', + text: 'Long text here...', + style: 'bullet-points', + }) + + expect(chatCompletionsCreate).toHaveBeenCalledTimes(1) + const [payload] = chatCompletionsCreate.mock.calls[0] + + // Verify system prompt includes bullet point instruction + const systemMessage = payload.messages.find( + (m: Record) => m.role === 'system', + ) + expect(systemMessage).toBeDefined() + expect(systemMessage.content).toContain('bullet point') + }) + + it('applies focus areas in the prompt', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'Qwen/Qwen2.5-72B-Instruct', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'Summary with focus' }, + finish_reason: 'stop', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('Qwen/Qwen2.5-72B-Instruct') + const textAdapter = (adapter as any).textAdapter + ;(textAdapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + await adapter.summarize({ + model: 'Qwen/Qwen2.5-72B-Instruct', + text: 'Long text about multiple topics...', + focus: ['technology', 'innovation'], + }) + + expect(chatCompletionsCreate).toHaveBeenCalledTimes(1) + const [payload] = chatCompletionsCreate.mock.calls[0] + + // Verify focus areas are in the system prompt + const systemMessage = payload.messages.find( + (m: Record) => m.role === 'system', + ) + expect(systemMessage.content).toContain('technology') + expect(systemMessage.content).toContain('innovation') + }) + }) + + describe('summarizeStream', () => { + it('streams summary content', async () => { + const mockStream = createMockChatCompletionsStream([ + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'This ' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: { content: 'is a summary.' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'deepseek-ai/DeepSeek-R1-0528', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + const chatCompletionsCreate = vi.fn().mockResolvedValueOnce(mockStream) + + const adapter = createAdapter('deepseek-ai/DeepSeek-R1-0528') + const textAdapter = (adapter as any).textAdapter + ;(textAdapter as any).client = { + chat: { + completions: { + create: chatCompletionsCreate, + }, + }, + } + + const chunks: StreamChunk[] = [] + for await (const chunk of adapter.summarizeStream({ + model: 'deepseek-ai/DeepSeek-R1-0528', + text: 'Long text to summarize...', + style: 'paragraph', + })) { + chunks.push(chunk) + } + + // Should have content chunks plus done chunk + expect(chunks.length).toBeGreaterThanOrEqual(2) + + // Find content chunks + const contentChunks = chunks.filter((c) => c.type === 'content') + expect(contentChunks.length).toBe(2) + + // Verify accumulated content + const lastContentChunk = contentChunks[contentChunks.length - 1] + if (lastContentChunk.type === 'content') { + expect(lastContentChunk.content).toBe('This is a summary.') + } + + // Verify done chunk + const doneChunk = chunks.find((c) => c.type === 'done') + expect(doneChunk).toBeDefined() + }) + }) +}) diff --git a/packages/typescript/ai-nebius/tsconfig.json b/packages/typescript/ai-nebius/tsconfig.json new file mode 100644 index 00000000..ea11c109 --- /dev/null +++ b/packages/typescript/ai-nebius/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "**/*.config.ts"] +} diff --git a/packages/typescript/ai-nebius/vite.config.ts b/packages/typescript/ai-nebius/vite.config.ts new file mode 100644 index 00000000..77bcc2e6 --- /dev/null +++ b/packages/typescript/ai-nebius/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/typescript/ai-nebius/vitest.config.ts b/packages/typescript/ai-nebius/vitest.config.ts new file mode 100644 index 00000000..fa253174 --- /dev/null +++ b/packages/typescript/ai-nebius/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3ed56e5..0e6a8d8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -695,6 +695,25 @@ importers: specifier: ^7.2.7 version: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/ai-nebius: + dependencies: + '@tanstack/ai': + specifier: workspace:^ + version: link:../ai + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.18.3)(zod@4.2.1) + zod: + specifier: ^4.0.0 + version: 4.2.1 + devDependencies: + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.0.15(@types/node@25.0.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: + specifier: ^7.2.7 + version: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/ai-ollama: dependencies: '@tanstack/ai': @@ -13736,6 +13755,11 @@ snapshots: ws: 8.18.3 zod: 4.1.13 + openai@6.10.0(ws@8.18.3)(zod@4.2.1): + optionalDependencies: + ws: 8.18.3 + zod: 4.2.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4