diff --git a/.cursor/rules/ai-sdk-integration.mdc b/.cursor/rules/ai-sdk-integration.mdc index e6638dd..58ce141 100644 --- a/.cursor/rules/ai-sdk-integration.mdc +++ b/.cursor/rules/ai-sdk-integration.mdc @@ -1,16 +1,23 @@ --- description: Guidance for integrating with the Vercel AI SDK --- + # AI SDK Integration -The provider implements the `LanguageModelV2` contract and is designed to work with the Vercel AI SDK. +The provider implements the `LanguageModelV3` and `EmbeddingModelV3` contracts +and is designed to work with the Vercel AI SDK 5.0+. ## Key Points -- Create models via the provider function: `const model = provider("gpt-4o")`. -- Supported operations include `doGenerate` and `doStream` (see [src/sap-ai-chat-language-model.ts](mdc:src/sap-ai-chat-language-model.ts)). +- Create language models via the provider function: `const model = provider("gpt-4o")`. +- Create embedding models via: `const model = provider.embedding("text-embedding-ada-002")`. +- Supported operations include `doGenerate` and `doStream` for language models, + `doEmbed` for embedding models (see + [src/sap-ai-language-model.ts](mdc:src/sap-ai-language-model.ts) and + [src/sap-ai-embedding-model.ts](mdc:src/sap-ai-embedding-model.ts)). - Tool calling is supported by passing `tools` in call options. -- Structured outputs (JSON schema response_format) are enabled for most models except Anthropic and Amazon families. +- Structured outputs (JSON schema response_format) are enabled for most models + except Anthropic and Amazon families. - `n` (multi-choice) is disabled for Amazon models. ## Recommended API Usage @@ -18,6 +25,6 @@ The provider implements the `LanguageModelV2` contract and is designed to work w - Text generation: `generateText` with `model: provider("")`. - Streaming: `streamText` for SSE token streams. - Structured outputs: `generateObject` when the model supports JSON schema. +- Embeddings: `embed` or `embedMany` with `model: provider.embedding("")`. Refer to examples in [README.md](mdc:README.md) and [examples/](mdc:examples/). - diff --git a/.cursor/rules/build-and-publish.mdc b/.cursor/rules/build-and-publish.mdc index f92462a..c5a2e5f 100644 --- a/.cursor/rules/build-and-publish.mdc +++ b/.cursor/rules/build-and-publish.mdc @@ -1,24 +1,28 @@ --- description: Build, type-check, test, and publish workflow for the package --- + # Build and Publish ## Build Outputs -- Bundler: tsup (CJS + ESM + DTS + sourcemaps) — see [tsup.config.ts](mdc:tsup.config.ts) +- Bundler: tsup (CJS + ESM + DTS + sourcemaps) — see + [tsup.config.ts](mdc:tsup.config.ts) - Output dir: [dist/](mdc:dist/) - Package exports configured in [package.json](mdc:package.json) ## Commands -- Clean: `npm run clean` - Build: `npm run build` - Type-check: `npm run type-check` - Lint: `npm run lint` -- Prettier check/fix: `npm run prettier-check` / `npm run prettier-fix` -- Test (all): `npm test` +- Lint-fix: `npm run lint-fix` +- Prettier-check: `npm run prettier-check` +- Prettier-fix: `npm run prettier-fix` +- Test: `npm test` -`prepublishOnly` enforces type-check, tests, and build before publish (see [package.json](mdc:package.json)). +`prepublishOnly` enforces type-check, lint, test, build and check-build before +publish (see [package.json](mdc:package.json)). ## Node Engine @@ -27,4 +31,3 @@ description: Build, type-check, test, and publish workflow for the package ## Verification - Verify distribution artifacts exist: `npm run check-build`. - diff --git a/.cursor/rules/environment-and-config.mdc b/.cursor/rules/environment-and-config.mdc index 822896f..e38c8a4 100644 --- a/.cursor/rules/environment-and-config.mdc +++ b/.cursor/rules/environment-and-config.mdc @@ -1,31 +1,61 @@ --- description: Environment variables, credentials, and configuration guidance --- + # Environment and Configuration ## Credentials -- Preferred: Provide an SAP AI Core service key JSON to `createSAPAIProvider({ serviceKey })` (see [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts)). -- Alternative: Provide an OAuth token via `createSAPAIProviderSync({ token })` or set `SAP_AI_TOKEN`. +The SAP AI SDK (`@sap-ai-sdk/orchestration`) handles authentication +automatically: -## Environment Variables +1. **On SAP BTP**: Uses service binding from `VCAP_SERVICES` +2. **Locally**: Uses `AICORE_SERVICE_KEY` environment variable containing the + full JSON service key -See details in [README.md](mdc:README.md). +## Provider Configuration -- `SAP_AI_SERVICE_KEY` — full JSON string of your service key. -- `SAP_AI_TOKEN` — direct OAuth token (used by default instance `sapai`). -- `SAP_AI_BASE_URL` — override base URL if needed. +Configure the provider using `createSAPAIProvider(options)`: -Do not commit secrets. Prefer local `.env` and CI secrets. `dotenv` is available for local development. +```typescript +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; -## Deployment and Resource Group +// Default configuration (auto-detects authentication) +const provider = createSAPAIProvider(); + +// With specific resource group +const provider = createSAPAIProvider({ + resourceGroup: "production", +}); + +// With specific deployment ID +const provider = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", +}); + +// With custom destination +const provider = createSAPAIProvider({ + destination: { + url: "https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com", + }, +}); +``` -- Default `deploymentId`: `d65d81e7c077e583` -- Default `resourceGroup`: `default` +## Environment Variables + +- `AICORE_SERVICE_KEY` — Full JSON string of your SAP AI Core service key (used + by SAP AI SDK) + +Do not commit secrets. Prefer local `.env` and CI secrets. `dotenv` is available +for local development. + +## Deployment and Resource Group -Both can be overridden in provider settings or via environment variables in your host app. +- Default `resourceGroup`: `'default'` -## SAP BTP (xsenv) +Both `resourceGroup` and `deploymentId` can be configured in provider settings. -For BTP environments, consider loading credentials from `VCAP_SERVICES` via `@sap/xsenv` as shown in [README.md](mdc:README.md). +## SAP BTP (VCAP_SERVICES) +For BTP environments, the SAP AI SDK automatically reads credentials from +`VCAP_SERVICES` when the application is bound to an AI Core service instance. diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc index a79342f..bcf724b 100644 --- a/.cursor/rules/project-structure.mdc +++ b/.cursor/rules/project-structure.mdc @@ -1,33 +1,46 @@ --- alwaysApply: true --- + # Project Structure Guide -This repository provides an SAP AI Core provider compatible with the Vercel AI SDK. Use these anchors to navigate the codebase quickly. +This repository provides an SAP AI Core provider compatible with the Vercel AI +SDK. Use these anchors to navigate the codebase quickly. ## Entry Points and Core Modules - Main package entry: [src/index.ts](mdc:src/index.ts) - Re-exports provider factory and types. -- Provider factory and default instance: [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts) - - `createSAPAIProvider(options)` returns a function for model creation (async; handles OAuth when given a service key). - - `createSAPAIProviderSync(options)` for token-based sync usage. - - `sapai` default instance reads `SAP_AI_TOKEN` at call time. -- Chat LLM implementation: [src/sap-ai-chat-language-model.ts](mdc:src/sap-ai-chat-language-model.ts) - - Implements Vercel AI SDK `LanguageModelV2` contract. - - Supports text, tool calls, streaming, structured outputs (varies by model family). -- Message templating: [src/convert-to-sap-messages.ts](mdc:src/convert-to-sap-messages.ts) -- Settings and model IDs: [src/sap-ai-chat-settings.ts](mdc:src/sap-ai-chat-settings.ts) +- Provider factory and default instance: + [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts) + - `createSAPAIProvider(options)` returns a provider function for model + creation. + - `sapai` default instance uses default configuration (auto-detects service + binding or AICORE_SERVICE_KEY env var). +- Language model implementation: + [src/sap-ai-language-model.ts](mdc:src/sap-ai-language-model.ts) + - Implements Vercel AI SDK `LanguageModelV3` contract (v4.0.0+). + - Supports text, tool calls, streaming, structured outputs (varies by model + family). +- Embedding model implementation: + [src/sap-ai-embedding-model.ts](mdc:src/sap-ai-embedding-model.ts) + - Implements Vercel AI SDK `EmbeddingModelV3` contract. + - Supports single and batch embedding generation. +- Message templating: + [src/convert-to-sap-messages.ts](mdc:src/convert-to-sap-messages.ts) +- Settings and model IDs: [src/sap-ai-settings.ts](mdc:src/sap-ai-settings.ts) + - Also re-exports all SAP AI SDK types for convenience. - Error handling helpers: [src/sap-ai-error.ts](mdc:src/sap-ai-error.ts) -- Types: [src/types/completion-response.ts](mdc:src/types/completion-response.ts), [src/types/completion-request.ts](mdc:src/types/completion-request.ts) ## Build, Types, and Distribution - TypeScript config: [tsconfig.json](mdc:tsconfig.json) - Build config (CJS+ESM+DTS): [tsup.config.ts](mdc:tsup.config.ts) - ESLint config: [eslint.config.mjs](mdc:eslint.config.mjs) -- Output: [dist/](mdc:dist/) produces `index.js`, `index.mjs`, `index.d.ts` with sourcemaps. -- `package.json` exports are aligned for Node and ESM: [package.json](mdc:package.json) +- Output: [dist/](mdc:dist/) produces `index.js`, `index.cjs`, `index.d.ts` with + sourcemaps. +- `package.json` exports are aligned for Node and ESM: + [package.json](mdc:package.json) ## Testing @@ -37,14 +50,38 @@ This repository provides an SAP AI Core provider compatible with the Vercel AI S ## Examples and Docs - README with usage and configuration: [README.md](mdc:README.md) +- Complete API reference: [API_REFERENCE.md](mdc:API_REFERENCE.md) +- Architecture documentation: [ARCHITECTURE.md](mdc:ARCHITECTURE.md) +- Contribution guidelines: [CONTRIBUTING.md](mdc:CONTRIBUTING.md) +- Environment setup guide: [ENVIRONMENT_SETUP.md](mdc:ENVIRONMENT_SETUP.md) +- Troubleshooting guide: [TROUBLESHOOTING.md](mdc:TROUBLESHOOTING.md) +- Migration guide: [MIGRATION_GUIDE.md](mdc:MIGRATION_GUIDE.md) +- cURL API testing guide: + [CURL_API_TESTING_GUIDE.md](mdc:CURL_API_TESTING_GUIDE.md) +- AI agent instructions: [AGENTS.md](mdc:AGENTS.md) - Examples: [examples/](mdc:examples/) - - Chat completion: [examples/example-simple-chat-completion.ts](mdc:examples/example-simple-chat-completion.ts) - - Tool calling: [examples/example-chat-completion-tool.ts](mdc:examples/example-chat-completion-tool.ts) - - Image recognition: [examples/example-image-recognition.ts](mdc:examples/example-image-recognition.ts) - - Text generation: [examples/example-generate-text.ts](mdc:examples/example-generate-text.ts) + - Chat completion: + [examples/example-simple-chat-completion.ts](mdc:examples/example-simple-chat-completion.ts) + - Tool calling: + [examples/example-chat-completion-tool.ts](mdc:examples/example-chat-completion-tool.ts) + - Image recognition: + [examples/example-image-recognition.ts](mdc:examples/example-image-recognition.ts) + - Text generation: + [examples/example-generate-text.ts](mdc:examples/example-generate-text.ts) + - Data masking: + [examples/example-data-masking.ts](mdc:examples/example-data-masking.ts) + - Streaming: + [examples/example-streaming-chat.ts](mdc:examples/example-streaming-chat.ts) + - Document grounding (RAG): + [examples/example-document-grounding.ts](mdc:examples/example-document-grounding.ts) + - Translation: + [examples/example-translation.ts](mdc:examples/example-translation.ts) + - Embeddings: + [examples/example-embeddings.ts](mdc:examples/example-embeddings.ts) ## Runtime Requirements - Node.js >= 18 (see `engines` in [package.json](mdc:package.json)). -- Environment variables are used for credentials when no service key is provided. - +- Authentication is handled automatically by SAP AI SDK: + - On SAP BTP: Uses service binding (VCAP_SERVICES) + - Locally: Uses AICORE_SERVICE_KEY environment variable diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 8030b83..22cd3c9 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -1,6 +1,7 @@ --- globs: **/*.test.ts,**/*.test.tsx --- + # Testing Guide (Vitest) ## Test Environments @@ -8,7 +9,8 @@ globs: **/*.test.ts,**/*.test.tsx - Node tests: [vitest.node.config.js](mdc:vitest.node.config.js) - Edge runtime tests: [vitest.edge.config.js](mdc:vitest.edge.config.js) -Use Node environment for most unit tests. Use Edge runtime when covering streaming or runtime-specific behaviors. +Use Node environment for most unit tests. Use Edge runtime when covering +streaming or runtime-specific behaviors. ## Commands @@ -19,12 +21,17 @@ Use Node environment for most unit tests. Use Edge runtime when covering streami ## Stubbing Network -- Prefer injecting a custom `fetch` via provider settings for deterministic tests (see [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts)). -- For streaming flows, assert `LanguageModelV2StreamPart` events via the `doStream` pipeline (see [src/sap-ai-chat-language-model.ts](mdc:src/sap-ai-chat-language-model.ts)). +- Prefer injecting a custom `fetch` via provider settings for deterministic + tests (see [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts)). +- For streaming flows, assert `LanguageModelV3StreamPart` events via the + `doStream` pipeline (see + [src/sap-ai-language-model.ts](mdc:src/sap-ai-language-model.ts)). ## Test Patterns -- Validate message templating: cover [src/convert-to-sap-messages.ts](mdc:src/convert-to-sap-messages.ts). -- Validate error propagation using helpers in [src/sap-ai-error.ts](mdc:src/sap-ai-error.ts). -- Assert structured output behavior varies by model family (Anthropic/Amazon do not support JSON schema formatting). - +- Validate message templating: cover + [src/convert-to-sap-messages.ts](mdc:src/convert-to-sap-messages.ts). +- Validate error propagation using helpers in + [src/sap-ai-error.ts](mdc:src/sap-ai-error.ts). +- Assert structured output behavior varies by model family (Anthropic/Amazon do + not support JSON schema formatting). diff --git a/.cursor/rules/typescript-style.mdc b/.cursor/rules/typescript-style.mdc index 5779b3a..0b61421 100644 --- a/.cursor/rules/typescript-style.mdc +++ b/.cursor/rules/typescript-style.mdc @@ -1,9 +1,11 @@ --- globs: *.ts,*.tsx,*.mts,*.cts --- + # TypeScript Style Guide -Follow these conventions across TypeScript sources to align with the existing configuration and code style. +Follow these conventions across TypeScript sources to align with the existing +configuration and code style. ## Language and Types @@ -20,17 +22,23 @@ Follow these conventions across TypeScript sources to align with the existing co ## Errors and Handling -- Do not swallow errors. Propagate or wrap with context via `SAPAIError` utilities when applicable (see [src/sap-ai-error.ts](mdc:src/sap-ai-error.ts)). -- Only use `try/catch` where meaningful recovery or re-throw with context is performed. +- Do not swallow errors. Propagate or wrap with context using Vercel AI SDK + standard errors (`APICallError`, `LoadAPIKeyError`, `NoSuchModelError` from + `@ai-sdk/provider`). +- Only use `try/catch` where meaningful recovery or re-throw with context is + performed. ## Formatting and Linting -- Respect [eslint.config.mjs](mdc:eslint.config.mjs) and Prettier (see `prettier-*` scripts in [package.json](mdc:package.json)). +- Respect [eslint.config.mjs](mdc:eslint.config.mjs) and Prettier (see + `prettier-*` scripts in [package.json](mdc:package.json)). - Keep lines readable; prefer multi-line over dense one-liners. - Avoid inline explanatory comments; add brief comments above non-obvious logic. ## Provider-specific Conventions -- The provider function must not be invoked with `new` (explicitly guarded in [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts)). -- When adding model capabilities, ensure `SAPAIChatLanguageModel` maintains the `LanguageModelV2` contract (see [src/sap-ai-chat-language-model.ts](mdc:src/sap-ai-chat-language-model.ts)). - +- The provider function must not be invoked with `new` (explicitly guarded in + [src/sap-ai-provider.ts](mdc:src/sap-ai-provider.ts)). +- When adding model capabilities, ensure `SAPAILanguageModel` maintains the + `LanguageModelV3` contract (see + [src/sap-ai-language-model.ts](mdc:src/sap-ai-language-model.ts)). diff --git a/.cursor/rules/usage-and-examples.mdc b/.cursor/rules/usage-and-examples.mdc index d87aba0..3e64f57 100644 --- a/.cursor/rules/usage-and-examples.mdc +++ b/.cursor/rules/usage-and-examples.mdc @@ -1,22 +1,40 @@ --- description: How to use the provider and where to find examples --- + # Usage and Examples -Start with the README’s Quick Start and Advanced sections: [README.md](mdc:README.md). +Start with the README’s Quick Start and Advanced sections: +[README.md](mdc:README.md). ## Examples -- Simple chat completion: [examples/example-simple-chat-completion.ts](mdc:examples/example-simple-chat-completion.ts) -- Tool calling: [examples/example-chat-completion-tool.ts](mdc:examples/example-chat-completion-tool.ts) -- Image recognition: [examples/example-image-recognition.ts](mdc:examples/example-image-recognition.ts) -- Text generation: [examples/example-generate-text.ts](mdc:examples/example-generate-text.ts) +- Simple chat completion: + [examples/example-simple-chat-completion.ts](mdc:examples/example-simple-chat-completion.ts) +- Tool calling: + [examples/example-chat-completion-tool.ts](mdc:examples/example-chat-completion-tool.ts) +- Image recognition: + [examples/example-image-recognition.ts](mdc:examples/example-image-recognition.ts) +- Text generation: + [examples/example-generate-text.ts](mdc:examples/example-generate-text.ts) +- Data masking: + [examples/example-data-masking.ts](mdc:examples/example-data-masking.ts) +- Streaming chat: + [examples/example-streaming-chat.ts](mdc:examples/example-streaming-chat.ts) +- Document grounding (RAG): + [examples/example-document-grounding.ts](mdc:examples/example-document-grounding.ts) +- Translation: + [examples/example-translation.ts](mdc:examples/example-translation.ts) +- Embeddings: + [examples/example-embeddings.ts](mdc:examples/example-embeddings.ts) ## Common Patterns -- Create provider with service key: `createSAPAIProvider({ serviceKey })`. -- Default instance with env token: `sapai("gpt-4o")`. -- Use with Vercel AI SDK: `generateText`, `streamText`, `generateObject`. +- Create provider: `createSAPAIProvider()` (auto-detects AICORE_SERVICE_KEY from + environment) +- With options: + `createSAPAIProvider({ resourceGroup: 'production', deploymentId: 'abc123' })` +- Default instance: `sapai("gpt-4o")` (uses default configuration) +- Use with Vercel AI SDK: `generateText`, `streamText`, `generateObject` See exported APIs: [src/index.ts](mdc:src/index.ts) - diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..493cb95 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +max_line_length = 100 + +[*.md{,c}] +max_line_length = off +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5a994c --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +# SAP AI Provider Environment Variables +# +# This file contains example environment variables needed for the SAP AI Provider. +# Copy this file to .env and fill in your actual values. +# +# Usage: +# cp .env.example .env +# # Edit .env with your values +# # Never commit .env (it's in .gitignore) + +# ============================================================================== +# REQUIRED: SAP AI Core Service Key (JSON format) +# ============================================================================== +# +# The complete service key JSON from SAP BTP cockpit. +# Get this from: SAP BTP Cockpit > Your Subaccount > Instances and Subscriptions +# > AI Core instance > Service Keys > Create/View Key +# +# IMPORTANT: This should be the entire JSON object as a single-line string. +# +# Example format: +# AICORE_SERVICE_KEY='{"clientid":"your-client-id","clientsecret":"your-secret","url":"https://your-auth-url.com","serviceurls":{"AI_API_URL":"https://your-ai-api.com"}}' +# +AICORE_SERVICE_KEY= + +# ============================================================================== +# OPTIONAL: Advanced Configuration +# ============================================================================== + +# Resource Group (default: "default") +# Specifies which SAP AI Core resource group to use +# AICORE_RESOURCE_GROUP=default + +# Deployment ID +# Specific deployment ID to use instead of the default orchestration endpoint +# Leave empty to use the default /v2/completion endpoint +# AICORE_DEPLOYMENT_ID= + +# ============================================================================== +# Development/Testing Variables +# ============================================================================== + +# Node.js environment +# NODE_ENV=development + +# Enable debug logging (if implemented) +# DEBUG=sap-ai-provider:* + +# ============================================================================== +# Notes +# ============================================================================== +# +# 1. SAP BTP Cloud Foundry: +# When deployed to SAP BTP, the service binding (VCAP_SERVICES) is used +# automatically. You don't need AICORE_SERVICE_KEY in this case. +# +# 2. Local Development: +# For local development, you MUST set AICORE_SERVICE_KEY with your +# service key JSON. +# +# 3. Security: +# - Never commit .env or service keys to version control +# - .env is already in .gitignore +# - Rotate service keys regularly +# - Use separate keys for development/production +# +# 4. Troubleshooting: +# - If you get authentication errors, verify your service key is valid +# - Ensure the JSON is properly formatted (use a JSON validator) +# - Check that your service key has the required permissions +# - See TROUBLESHOOTING.md for more help +# +# 5. Documentation: +# - Setup guide: ENVIRONMENT_SETUP.md +# - API reference: API_REFERENCE.md +# - Troubleshooting: TROUBLESHOOTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 41f9b84..1555dbc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,23 +7,64 @@ assignees: "" --- ### Description + A clear and concise description of the bug. ### Steps to Reproduce + 1. 2. 3. ### Expected Behavior + ### Actual Behavior - + + + +### Code Example + + + +```typescript +// Your code here +``` ### Environment -- Version: -- Node.js: -- OS: + +- **Package Version:** +- **Node.js Version:** +- **OS:** +- **Vercel AI SDK Version:** + +### SAP AI Core Configuration + +- **Model Used:** +- **Deployment ID:** +- **Authentication Method:** + +### Error Logs + + + +``` +Paste error logs here +``` + +### Related Issues/PRs + + ### Additional Context - \ No newline at end of file + + + +### Checklist + +- [ ] I've searched existing issues and this is not a duplicate +- [ ] I've included a minimal code example that reproduces the issue +- [ ] I've checked the + [Troubleshooting Guide](https://github.com/jerome-benoit/sap-ai-provider/blob/main/TROUBLESHOOTING.md) +- [ ] I've included relevant error logs diff --git a/.github/ISSUE_TEMPLATE/feature_report.md b/.github/ISSUE_TEMPLATE/feature_report.md index cafe4f6..1abfd37 100644 --- a/.github/ISSUE_TEMPLATE/feature_report.md +++ b/.github/ISSUE_TEMPLATE/feature_report.md @@ -7,16 +7,75 @@ assignees: "" --- ### Summary - -### Problem to solve + + +### Problem Statement + -### Proposed solution - +**Is your feature request related to a problem?** + + + +### Proposed Solution + + + +**Desired API/Usage:** + +```typescript +// Example of how you'd like to use this feature +``` + +**Expected Behavior:** + + + +### Use Case + + + +### Alternatives Considered + + + +### SAP AI Core Context + +- [ ] This feature would require changes to SAP AI Core integration +- [ ] This feature affects model configuration +- [ ] This feature impacts authentication/authorization +- [ ] This feature is specific to certain models (list them): + +### Impact + +**Who benefits from this feature?** + + + +**Priority (from your perspective):** + +- [ ] High - Blocking my workflow +- [ ] Medium - Would improve my workflow significantly +- [ ] Low - Nice to have + +### Implementation Suggestions + + + +### Related Issues/PRs + + + +### Additional Context + + -### Alternatives considered - +### Checklist -### Additional context - \ No newline at end of file +- [ ] I've searched existing issues and this is not a duplicate +- [ ] I've checked the + [API Reference](https://github.com/jerome-benoit/sap-ai-provider/blob/main/API_REFERENCE.md) + to ensure this doesn't already exist +- [ ] I've provided a clear use case and example +- [ ] I've considered backwards compatibility diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6376de3..e077ad0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,9 +4,17 @@ SAP AI Provider is a TypeScript/Node.js library that provides seamless integrati Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here. +## Table of Contents + +- [Working Effectively](#working-effectively) +- [Validation](#validation) +- [Common Tasks](#common-tasks) +- [Pull Request Review Guidelines](#pull-request-review-guidelines) + ## Working Effectively ### Bootstrap and Install Dependencies + - **Prerequisites**: Node.js 18+ and npm are required - **Fresh install**: `npm install` -- takes ~25 seconds. NEVER CANCEL. Set timeout to 60+ seconds. - Use `npm install` when no package-lock.json exists (fresh clone) @@ -17,6 +25,7 @@ Always reference these instructions first and fallback to search or bash command - Faster than `npm install` for CI/existing setups ### Building + - **Build the library**: `npm run build` -- takes ~3 seconds. Set timeout to 15+ seconds. - Uses tsup to create CommonJS, ESM, and TypeScript declaration files - Outputs to `dist/` directory: `index.js`, `index.mjs`, `index.d.ts`, `index.d.mts` @@ -24,47 +33,55 @@ Always reference these instructions first and fallback to search or bash command - Verifies all expected files exist and lists directory contents ### Testing + - **Run all tests**: `npm run test` -- takes ~1 second. Set timeout to 15+ seconds. - **Run Node.js specific tests**: `npm run test:node` -- takes ~1 second. Set timeout to 15+ seconds. - **Run Edge runtime tests**: `npm run test:edge` -- takes ~1 second. Set timeout to 15+ seconds. - **Watch mode for development**: `npm run test:watch` ### Type Checking and Linting + - **Type checking**: `npm run type-check` -- takes ~2 seconds. Set timeout to 15+ seconds. - **Prettier formatting check**: `npm run prettier-check` -- takes ~1 second. Set timeout to 10+ seconds. - **Auto-fix formatting**: `npm run prettier-fix` -- **Linting**: `npm run lint` -- **CURRENTLY FAILS** due to missing eslint.config.js file. Do not use until fixed. +- **Linting**: `npm run lint` -- takes ~1 second. Set timeout to 15+ seconds. +- **Auto-fix linting issues**: `npm run lint-fix` ### Development Workflow -1. **Always run the bootstrap steps first**: `npm ci` -2. **Make your changes** to TypeScript files in `/src` -3. **Run type checking**: `npm run type-check` -4. **Run tests**: `npm run test` -5. **Check formatting**: `npm run prettier-check` (fix with `npm run prettier-fix` if needed) -6. **Build the library**: `npm run build` -7. **Verify build outputs**: `npm run check-build` + +**For comprehensive workflow and standards**, see [Contributing Guide](../CONTRIBUTING.md#development-workflow) + +**Quick workflow summary:** + +1. **Bootstrap**: `npm ci` (always first) +2. **Make changes** in `/src` +3. **Validate**: `npm run type-check && npm run test && npm run prettier-check` +4. **Build**: `npm run build && npm run check-build` ## Validation ### Pre-commit Requirements -- **ALWAYS run these commands before committing or the CI will fail**: - - `npm run type-check` - - `npm run test` - - `npm run test:node` - - `npm run test:edge` - - `npm run prettier-check` - - `npm run build` - - `npm run check-build` -- **Do NOT run `npm run lint`** until the ESLint configuration is fixed + +**ALWAYS run this command before committing (CI will fail otherwise):** + +```bash +npm run type-check && npm run test && npm run test:node && npm run test:edge && npm run prettier-check && npm run lint && npm run build && npm run check-build +``` + +**Detailed checklist and standards**: See [Contributing Guide - Pre-Commit Checklist](../CONTRIBUTING.md#pre-commit-checklist) ### Manual Testing with Examples -- **Examples location**: `/examples` directory contains 4 example files + +**For environment setup and authentication**, see [Environment Setup](../ENVIRONMENT_SETUP.md) + +- **Examples location**: `/examples` directory contains 9 example files - **Running examples**: `npx tsx examples/example-simple-chat-completion.ts` -- **LIMITATION**: Examples require `SAP_AI_SERVICE_KEY` environment variable to work + ⚠️ **Important:** Examples require `AICORE_SERVICE_KEY` environment variable to work - **Without service key**: Examples will fail with clear error message about missing environment variable -- **With service key**: Create `.env` file with `SAP_AI_SERVICE_KEY=` +- **With service key**: Create `.env` file with `AICORE_SERVICE_KEY=` ### Complete End-to-End Validation Scenario + Since full example testing requires SAP credentials, validate changes using this comprehensive approach: 1. **Install and setup**: `npm install` (or `npm ci` if lock file exists) @@ -73,62 +90,91 @@ Since full example testing requires SAP credentials, validate changes using this 4. **Type check passes**: `npm run type-check` 5. **Formatting is correct**: `npm run prettier-check` 6. **Try running an example**: `npx tsx examples/example-simple-chat-completion.ts` -7. **Expected result**: Clear error message about missing `SAP_AI_SERVICE_KEY` +7. **Expected result**: Clear error message about missing `AICORE_SERVICE_KEY` **Complete CI-like validation command:** + ```bash -npm run type-check && npm run test && npm run test:node && npm run test:edge && npm run prettier-check && npm run build && npm run check-build +npm run type-check && npm run test && npm run test:node && npm run test:edge && npm run prettier-check && npm run lint && npm run build && npm run check-build ``` + This should complete in under 15 seconds total and all commands should pass. ## Common Tasks ### Repository Structure + ``` . ├── .github/ # GitHub Actions workflows and configs -├── examples/ # Example usage files (4 examples) +├── examples/ # Example usage files (9 examples) ├── src/ # TypeScript source code -│ ├── types/ # Type definitions -│ ├── index.ts # Main exports -│ ├── sap-ai-provider.ts # Main provider implementation -│ ├── sap-ai-chat-language-model.ts # Language model implementation -│ ├── sap-ai-chat-settings.ts # Settings and model types -│ ├── sap-ai-error.ts # Error handling -│ └── convert-to-sap-messages.ts # Message conversion utilities +│ ├── index.ts # Main exports +│ ├── sap-ai-provider.ts # Main provider implementation +│ ├── sap-ai-language-model.ts # Language model implementation +│ ├── sap-ai-embedding-model.ts # Embedding model implementation +│ ├── sap-ai-settings.ts # Settings and model types +│ ├── sap-ai-error.ts # Error handling +│ └── convert-to-sap-messages.ts # Message conversion utilities ├── dist/ # Build outputs (gitignored) ├── package.json # Dependencies and scripts ├── tsconfig.json # TypeScript configuration ├── tsup.config.ts # Build configuration ├── vitest.node.config.js # Node.js test configuration ├── vitest.edge.config.js # Edge runtime test configuration -└── README.md # Project documentation +├── README.md # Getting started and usage guide +├── API_REFERENCE.md # Complete API documentation +├── ARCHITECTURE.md # Technical architecture and design +├── CONTRIBUTING.md # Contribution guidelines and standards +├── ENVIRONMENT_SETUP.md # Authentication and environment configuration +├── TROUBLESHOOTING.md # Common issues and solutions +├── MIGRATION_GUIDE.md # Version migration instructions +├── CURL_API_TESTING_GUIDE.md # Direct SAP AI Core API testing +└── AGENTS.md # AI agent instructions ``` ### Key Files to Understand + +**Core Source Code:** + - **`src/index.ts`**: Main export file - start here to understand the public API - **`src/sap-ai-provider.ts`**: Core provider implementation -- **`src/sap-ai-chat-language-model.ts`**: Main language model logic +- **`src/sap-ai-language-model.ts`**: Main language model logic +- **`src/sap-ai-embedding-model.ts`**: Embedding model for vector generation - **`package.json`**: All available npm scripts and dependencies - **`examples/`**: Working examples of how to use the library +**Documentation:** + +- **`README.md`**: Quick start guide and basic usage +- **`API_REFERENCE.md`**: Complete API documentation with all exports, types, and models +- **`ARCHITECTURE.md`**: System architecture and design decisions +- **`CONTRIBUTING.md`**: Development workflow, coding standards, and guidelines +- **`ENVIRONMENT_SETUP.md`**: Authentication setup and SAP AI Core configuration +- **`TROUBLESHOOTING.md`**: Common problems and their solutions +- **`MIGRATION_GUIDE.md`**: Version migration instructions (v1.x → v2.x → v3.x → v4.x) +- **`CURL_API_TESTING_GUIDE.md`**: Direct API testing without the SDK + ### CI/CD Pipeline + - **GitHub Actions**: `.github/workflows/check-pr.yaml` runs on PRs and pushes -- **CI checks**: format-check, type-check, test (all environments), build, publish-check +- **CI checks**: format-check, type-check, test, build, publish-check - **Publishing**: `.github/workflows/npm-publish-npm-packages.yml` publishes on releases - **Build matrix**: Tests run in both Node.js and Edge runtime environments ### Package Dependencies + - **Runtime**: `@ai-sdk/provider`, `@ai-sdk/provider-utils`, `zod` - **Peer**: `ai` (Vercel AI SDK), `zod` - **Dev**: TypeScript, Vitest, tsup, ESLint, Prettier, dotenv - **Node requirement**: Node.js 18+ ### Common Commands Quick Reference + ```bash # Fresh setup (no package-lock.json) npm install # ~25s - Install deps + auto-build -# or existing setup (with package-lock.json) +# or existing setup (with package-lock.json) npm ci # ~15s - Clean install + auto-build # Development @@ -140,114 +186,104 @@ npm run build # ~3s - Build library npm run check-build # <1s - Verify build outputs npm run prettier-check # ~1s - Check formatting -# Complete validation (CI-like) -npm run type-check && npm run test && npm run test:node && npm run test:edge && npm run prettier-check && npm run build && npm run check-build -# Total time: ~15s +# Complete validation +npm run type-check && npm run test && npm run test:node && npm run test:edge && npm run prettier-check && npm run lint && npm run build && npm run check-build +# Total time: ~16s # Examples (requires SAP service key) +npx tsx examples/example-generate-text.ts npx tsx examples/example-simple-chat-completion.ts +npx tsx examples/example-streaming-chat.ts npx tsx examples/example-chat-completion-tool.ts -npx tsx examples/example-generate-text.ts npx tsx examples/example-image-recognition.ts +npx tsx examples/example-data-masking.ts +npx tsx examples/example-document-grounding.ts +npx tsx examples/example-translation.ts +npx tsx examples/example-embeddings.ts ``` ### Known Issues -- **ESLint**: The `npm run lint` command fails due to missing `eslint.config.js` configuration + - **Examples**: Cannot be fully tested without valid SAP AI service key credentials - **Deprecation warning**: Vitest shows CJS Node API deprecation warning (non-blocking) ### Troubleshooting + +**For comprehensive troubleshooting guide**, see [Troubleshooting Guide](../TROUBLESHOOTING.md) + +**Quick fixes:** + - **Build fails**: Check TypeScript errors with `npm run type-check` - **Tests fail**: Run `npm run test:watch` for detailed test output - **Formatting issues**: Use `npm run prettier-fix` to auto-fix - **Missing dependencies**: Delete `node_modules` and `package-lock.json`, then run `npm ci` -- **Example errors**: Verify `.env` file exists with valid `SAP_AI_SERVICE_KEY` +- **Example errors**: Verify `.env` file exists with valid `AICORE_SERVICE_KEY` ## Pull Request Review Guidelines +**For complete coding standards and contribution process**, see [Contributing Guide](../CONTRIBUTING.md) + When acting as a PR reviewer, you must first thoroughly analyze and understand the entire codebase before providing any reviews. Follow this comprehensive review process: ### Pre-Review Codebase Analysis **ALWAYS start by understanding the codebase:** -1. **Read core architecture**: Review `ARCHITECTURE.md`, `README.md`, and `CONTRIBUTING.md` + +1. **Read core architecture and guidelines**: + - `ARCHITECTURE.md` - System design and component interactions + - `README.md` - Quick start and usage patterns + - `CONTRIBUTING.md` - Development workflow and coding standards + - `API_REFERENCE.md` - Complete API documentation 2. **Understand the API surface**: Start with `src/index.ts` to see public exports -3. **Study key components**: Review `src/sap-ai-provider.ts` and `src/sap-ai-chat-language-model.ts` +3. **Study key components**: Review `src/sap-ai-provider.ts` and `src/sap-ai-language-model.ts` 4. **Check existing patterns**: Look at test files (`*.test.ts`) to understand testing patterns 5. **Review examples**: Check `/examples` directory for usage patterns 6. **Understand build/test setup**: Check `package.json`, `tsconfig.json`, and config files ### Coding Standards Enforcement -Ensure all changes comply with established standards: +**For complete coding standards**, see [Contributing Guide - Coding Standards](../CONTRIBUTING.md#coding-standards) -**TypeScript Standards:** -- Strict TypeScript configuration must be maintained (`strict: true`) -- All public APIs must have comprehensive JSDoc comments with examples -- Interfaces and types should be exported when part of public API -- Use `zod` schemas for runtime validation of external data -- Prefer explicit typing over `any` or implicit types +**Key requirements:** -**Code Formatting:** -- Prettier configuration must be followed (2 spaces, existing config) -- Run `npm run prettier-check` to verify formatting -- No manual spacing/formatting changes if Prettier handles it - -**Documentation Standards:** -- JSDoc comments required for all public functions/classes/interfaces -- Include `@example` blocks for complex APIs -- Update README.md if public API changes -- Update CHANGELOG.md for any user-facing changes - -**Error Handling:** -- Use custom `SAPAIError` class for provider-specific errors -- Provide clear, actionable error messages -- Include error context and debugging information -- Follow existing error patterns in the codebase +- Strict TypeScript with JSDoc for all public APIs +- Follow Prettier/ESLint configuration (`npm run prettier-check && npm run lint`) +- Use Vercel AI SDK standard errors with clear messages +- Update documentation (README.md, API_REFERENCE.md) for API changes ### Architecture and Design Compliance -**Provider Integration Patterns:** -- Must implement Vercel AI SDK interfaces correctly (`ProviderV2`, etc.) -- Follow existing pattern of separating provider factory from language model -- Maintain compatibility with both Node.js and Edge runtime environments -- Use existing authentication and request handling patterns +**For complete architecture guidelines**, see [Contributing Guide - Architecture Guidelines](../CONTRIBUTING.md#architecture-guidelines) -**Modularity Requirements:** -- Keep components focused and single-purpose -- Place types in appropriate locations (`src/types/` for complex schemas) -- Maintain separation between core logic and utility functions -- Follow existing file naming conventions +**Key patterns to follow:** -**Performance Considerations:** -- Streaming responses should be handled efficiently -- Avoid blocking operations in request/response flow -- Use existing caching patterns where applicable -- Consider memory usage for large responses +- Implement Vercel AI SDK interfaces correctly (`ProviderV3`, etc.) +- Maintain Node.js and Edge runtime compatibility +- Keep components focused and single-purpose +- Follow existing authentication and caching patterns ### Testing Requirements -**Test Coverage:** -- New features require corresponding test files (`*.test.ts`) -- Tests must pass in both Node.js (`npm run test:node`) and Edge (`npm run test:edge`) environments -- Use existing test patterns and utilities (Vitest, mocking patterns) -- Include both unit tests and integration tests where appropriate +**For complete testing guidelines**, see [Contributing Guide - Testing Guidelines](../CONTRIBUTING.md#testing-guidelines) + +**Essential checks:** -**Test Quality:** -- Tests should cover error conditions and edge cases -- Mock external dependencies appropriately -- Test files should mirror source file structure -- Use descriptive test names and clear assertions +- Tests must pass in both Node.js (`npm run test:node`) and Edge (`npm run test:edge`) +- Use existing Vitest patterns and mocking utilities +- Cover error conditions and edge cases +- Test files mirror source file structure ### Security Review **Credential Handling:** + - Never expose service keys or tokens in logs/errors - Follow existing patterns for secure credential management - Validate all external inputs using zod schemas - Check for potential injection vulnerabilities **API Security:** + - Ensure proper authentication headers are required - Validate response data structure before processing - Handle network errors gracefully @@ -256,23 +292,26 @@ Ensure all changes comply with established standards: ### Pre-Commit Validation Checklist Before approving any PR, verify ALL of these pass: + ```bash npm run type-check && npm run test && npm run test:node && npm run test:edge && npm run prettier-check && +npm run lint && npm run build && npm run check-build ``` **Documentation Checks:** + - [ ] JSDoc comments added/updated for public APIs - [ ] README.md updated if public API changed -- [ ] CHANGELOG.md updated for user-facing changes - [ ] Examples still work (verify error handling if no SAP credentials) **Code Quality Checks:** + - [ ] Follows existing TypeScript patterns and strictness - [ ] Proper error handling with meaningful messages - [ ] Tests cover new functionality and edge cases @@ -280,6 +319,7 @@ npm run check-build - [ ] Performance impact considered for new features **Integration Checks:** + - [ ] Compatible with Vercel AI SDK patterns - [ ] Works in both Node.js and Edge runtime environments - [ ] Maintains backward compatibility @@ -288,19 +328,22 @@ npm run check-build ### Review Tone and Approach **Be Constructive:** + - Explain the "why" behind requested changes - Reference existing code patterns as examples - Suggest specific improvements rather than just pointing out issues - Acknowledge good practices when you see them **Be Thorough:** + - Check for consistency with existing codebase patterns - Verify that changes align with architecture decisions - Look for potential side effects of changes - Consider maintainability and future extensibility **Be Educational:** + - Share knowledge about best practices - Point to relevant documentation or examples - Help contributors understand the project's standards -- Suggest resources for learning when appropriate \ No newline at end of file +- Suggest resources for learning when appropriate diff --git a/.github/workflows/check-pr.yaml b/.github/workflows/check-pr.yaml index 86adcbb..4e13eaa 100644 --- a/.github/workflows/check-pr.yaml +++ b/.github/workflows/check-pr.yaml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install dependencies run: npm ci - name: Run lint @@ -30,11 +30,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 - name: Install dependencies run: npm ci - name: Run type-check @@ -45,11 +45,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 - name: Install dependencies run: npm ci - name: Run tests @@ -64,11 +64,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 - name: Install dependencies run: npm ci - name: Build package @@ -83,20 +83,38 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: "npm" - name: Install dependencies run: npm ci - name: Build package run: npm run build - - name: Simulate npm publish (dry run) - run: npm publish --dry-run + - name: Determine npm tag for dry-run + id: tag + run: | + VERSION=$(node -p "require('./package.json').version") + if [[ "$VERSION" =~ -alpha ]]; then + echo "tag=alpha" >> $GITHUB_OUTPUT + elif [[ "$VERSION" =~ -beta ]]; then + echo "tag=beta" >> $GITHUB_OUTPUT + elif [[ "$VERSION" =~ -rc ]]; then + echo "tag=next" >> $GITHUB_OUTPUT + elif [[ "$VERSION" =~ -canary ]]; then + echo "tag=canary" >> $GITHUB_OUTPUT + elif [[ "$VERSION" =~ - ]]; then + echo "tag=next" >> $GITHUB_OUTPUT + else + echo "tag=latest" >> $GITHUB_OUTPUT + fi + echo "Detected version: $VERSION, using tag: $(cat $GITHUB_OUTPUT | grep tag | cut -d= -f2)" + - name: Simulate npm publish + run: npm publish --dry-run --tag ${{ steps.tag.outputs.tag }} - name: Check publishable files run: | echo "📦 Checking package contents:" npm pack --dry-run 2>/dev/null | head -20 || true - echo "✅ Package can be published successfully" \ No newline at end of file + echo "✅ Package can be published successfully" diff --git a/.github/workflows/npm-publish-npm-packages.yml b/.github/workflows/npm-publish-npm-packages.yml index d4097a8..cc11084 100644 --- a/.github/workflows/npm-publish-npm-packages.yml +++ b/.github/workflows/npm-publish-npm-packages.yml @@ -1,6 +1,3 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages - name: Node.js Package on: @@ -11,10 +8,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 - name: Validate lockfile synchronization run: npm ci --dry-run --ignore-scripts - run: npm ci @@ -26,20 +23,41 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - packages: write + id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: 20 - registry-url: https://registry.npmjs.org/ + node-version: 24 - name: Set package version from release run: | VERSION=${GITHUB_REF#refs/tags/v} echo "Setting version to $VERSION" - npm version $VERSION --no-git-tag-version + npm version $VERSION --no-git-tag-version --allow-same-version - run: npm ci - run: npm run build - - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - name: Determine npm tag + id: tag + run: | + VERSION=${GITHUB_REF#refs/tags/v} + if [[ "$VERSION" =~ -alpha ]]; then + echo "tag=alpha" >> $GITHUB_OUTPUT + echo "Publishing as 'alpha' tag for version $VERSION" + elif [[ "$VERSION" =~ -beta ]]; then + echo "tag=beta" >> $GITHUB_OUTPUT + echo "Publishing as 'beta' tag for version $VERSION" + elif [[ "$VERSION" =~ -rc ]]; then + echo "tag=next" >> $GITHUB_OUTPUT + echo "Publishing as 'next' tag for release candidate $VERSION" + elif [[ "$VERSION" =~ -canary ]]; then + echo "tag=canary" >> $GITHUB_OUTPUT + echo "Publishing as 'canary' tag for version $VERSION" + elif [[ "$VERSION" =~ - ]]; then + echo "tag=next" >> $GITHUB_OUTPUT + echo "Publishing as 'next' tag for prerelease version $VERSION" + else + echo "tag=latest" >> $GITHUB_OUTPUT + echo "Publishing as 'latest' tag for stable version $VERSION" + fi + - name: Publish to npm + run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.tag }} diff --git a/.gitignore b/.gitignore index d948596..63c19f2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ coverage/ .nyc_output # ESLint cache -.eslintcache \ No newline at end of file +.eslintcache + +# Serena +.serena/ diff --git a/.markdown-link-check.json b/.markdown-link-check.json new file mode 100644 index 0000000..d34a164 --- /dev/null +++ b/.markdown-link-check.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/markdown-link-check.json", + "ignorePatterns": [ + { + "pattern": "^https://<" + }, + { + "pattern": "^https://\\{" + }, + { + "pattern": "^https://your-" + }, + { + "pattern": "^https://example\\.com" + }, + { + "pattern": "^https://api\\.ai\\.prod\\.region" + }, + { + "pattern": "^https://api\\.weather\\.com" + }, + { + "pattern": "^https://github\\.com/YOUR-USERNAME" + }, + { + "pattern": "^https://\\.\\.\\." + }, + { + "pattern": "^http://localhost" + } + ], + "replacementPatterns": [], + "httpHeaders": [], + "timeout": "10s", + "retryOn429": true, + "retryCount": 2, + "fallbackRetryDelay": "5s", + "aliveStatusCodes": [200, 206, 403] +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..8dcddb7 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +openspec/AGENTS.md +LICENSE.md diff --git a/.markdownlintrc b/.markdownlintrc new file mode 100644 index 0000000..272a2e3 --- /dev/null +++ b/.markdownlintrc @@ -0,0 +1,32 @@ +{ + "default": true, + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD033": { + "allowed_elements": [ + "details", + "summary", + "br", + "sub", + "sup", + "kbd", + "img", + "a", + "div", + "span", + "picture", + "source" + ] + }, + "MD046": { + "style": "fenced" + }, + "MD049": { + "style": "underscore" + }, + "MD050": { + "style": "asterisk" + } +} diff --git a/.opencode/command/openspec-apply.md b/.opencode/command/openspec-apply.md new file mode 100644 index 0000000..7cedff8 --- /dev/null +++ b/.opencode/command/openspec-apply.md @@ -0,0 +1,30 @@ +--- +description: Implement an approved OpenSpec change and keep tasks in sync. +--- + +The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user. + +$ARGUMENTS + + + + +**Guardrails** + +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. + +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** + +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.opencode/command/openspec-archive.md b/.opencode/command/openspec-archive.md new file mode 100644 index 0000000..5b564fb --- /dev/null +++ b/.opencode/command/openspec-archive.md @@ -0,0 +1,30 @@ +--- +description: Archive a deployed OpenSpec change and update specs. +--- + + + $ARGUMENTS + + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** + +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict --no-interactive` and inspect with `openspec show ` if anything looks off. + +**Reference** + +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.opencode/command/openspec-proposal.md b/.opencode/command/openspec-proposal.md new file mode 100644 index 0000000..35ca4f5 --- /dev/null +++ b/.opencode/command/openspec-proposal.md @@ -0,0 +1,35 @@ +--- +description: Scaffold a new OpenSpec change and validate strictly. +--- + +The user has requested the following change proposal. Use the openspec instructions to create their change proposal. + +$ARGUMENTS + + + + +**Guardrails** + +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** + +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict --no-interactive` and resolve every issue before sharing the proposal. + +**Reference** + +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3ed0451 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ + + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: + +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: + +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + + + +# AI Agent Instructions + + + +This file provides the entry point for AI coding assistants working with this codebase. + +## Agent Instruction Files + +### GitHub Copilot + +See **`.github/copilot-instructions.md`** for comprehensive development instructions including: + +- Project overview and architecture +- Build, test, and validation workflows +- Pull request review guidelines +- Coding standards and best practices + +### Cursor + +See **`.cursor/rules/`** directory for modular topic-specific rules: + +- `ai-sdk-integration.mdc` - Vercel AI SDK patterns +- `build-and-publish.mdc` - Build and publish workflow +- `environment-and-config.mdc` - Environment and configuration +- `project-structure.mdc` - Project organization +- `testing.mdc` - Testing strategy +- `typescript-style.mdc` - TypeScript conventions +- `usage-and-examples.mdc` - Usage patterns + +### OpenSpec Workflows + +See **`openspec/AGENTS.md`** for spec-driven development instructions including: + +- Creating change proposals +- Writing spec deltas +- Validation and archiving +- OpenSpec CLI commands + +## For Human Developers + +- **Project documentation:** [README](./README.md) +- **Contributing guidelines:** [Contributing Guide](./CONTRIBUTING.md) +- **Architecture:** [Architecture](./ARCHITECTURE.md) diff --git a/API_REFERENCE.md b/API_REFERENCE.md index 95cd184..1782102 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -2,106 +2,794 @@ Complete API documentation for the SAP AI Core Provider. +## Terminology + +To avoid confusion, this documentation uses the following terminology +consistently: + +- **SAP AI Core** - The SAP BTP service that provides AI model hosting and + orchestration (the cloud service) +- **SAP AI SDK** - The official `@sap-ai-sdk/orchestration` npm package used for + API communication +- **SAP AI Core Provider** or **this provider** - This npm package + (`@jerome-benoit/sap-ai-provider`) +- **Tool calling** - The capability of models to invoke external functions + (equivalent to "function calling") + ## Table of Contents +- [Terminology](#terminology) - [Provider Factory Functions](#provider-factory-functions) + - [`createSAPAIProvider(options?)`](#createsapaiprovideroptions) + - [`sapai`](#sapai) +- [Models](#models) + - [Supported Models](#supported-models) + - [Model Capabilities Comparison](#model-capabilities-comparison) + - [Model Selection Guide by Use Case](#model-selection-guide-by-use-case) + - [Performance vs Quality Trade-offs](#performance-vs-quality-trade-offs) +- [Tool Calling (Function Calling)](#tool-calling-function-calling) + - [Overview](#overview) + - [Basic Tool Calling Example](#basic-tool-calling-example) + - [Model-Specific Tool Limitations](#model-specific-tool-limitations) + - [Tool Definition Format](#tool-definition-format) + - [Parallel Tool Calls](#parallel-tool-calls) + - [Multi-Turn Tool Conversations](#multi-turn-tool-conversations) + - [Error Handling with Tools](#error-handling-with-tools) + - [Streaming with Tools](#streaming-with-tools) + - [Advanced: Tool Choice Control](#advanced-tool-choice-control) + - [Best Practices](#best-practices) + - [Related Documentation](#related-documentation) +- [Embeddings](#embeddings) + - [Overview](#overview-1) + - [Basic Usage](#basic-usage) + - [Embedding Settings](#embedding-settings) + - [SAPAIEmbeddingModel](#sapaiembeddingmodel) + - [SAPAIEmbeddingSettings](#sapaiembeddingsettings) + - [SAPAIEmbeddingModelId](#sapaiembeddingmodelid) - [Interfaces](#interfaces) + - [`SAPAIProvider`](#sapaiprovider) + - [`provider(modelId, settings?)`](#providermodelid-settings) + - [`provider.chat(modelId, settings?)`](#providerchatmodelid-settings) + - [`SAPAIProviderSettings`](#sapaiprovidersettings) + - [`SAPAISettings`](#sapaisettings) + - [`ModelParams`](#modelparams) + - [`SAPAIServiceKey`](#sapaiservicekey) + - [`MaskingModuleConfig`](#maskingmoduleconfig) + - [`DpiConfig`](#dpiconfig) +- [Provider Options](#provider-options) + - [`SAP_AI_PROVIDER_NAME`](#sap-ai-provider-name-constant) + - [`sapAILanguageModelProviderOptions`](#sapailanguagemodelprovideroptions) + - [`sapAIEmbeddingProviderOptions`](#sapaiembeddingprovideroptions) + - [`SAPAILanguageModelProviderOptions` (Type)](#sapailanguagemodelprovideroptions-type) + - [`SAPAIEmbeddingProviderOptions` (Type)](#sapaiembeddingprovideroptions-type) - [Types](#types) + - [`SAPAIModelId`](#sapaimodelid) + - [`DpiEntities`](#dpientities) - [Classes](#classes) + - [`SAPAILanguageModel`](#sapailanguagemodel) + - [`doGenerate(options)`](#dogenerateoptions) + - [`doStream(options)`](#dostreamoptions) + - [Error Handling & Reference](#error-handling--reference) + - [Error Types](#error-types) + - [SAP-Specific Error Details](#sap-specific-error-details) + - [Error Handling Examples](#error-handling-examples) + - [HTTP Status Code Reference](#http-status-code-reference) + - [Error Handling Strategy](#error-handling-strategy) + - [`OrchestrationErrorResponse`](#orchestrationerrorresponse) - [Utility Functions](#utility-functions) - ---- + - [`getProviderName(providerIdentifier)`](#getprovidernameprovideridentifier) + - [`buildDpiMaskingProvider(config)`](#builddpimaskingproviderconfig) + - [`buildAzureContentSafetyFilter(type, config?)`](#buildazurecontentsafetyfiltertype-config) + - [`buildLlamaGuard38BFilter(type, categories)`](#buildllamaguard38bfiltertype-categories) + - [`buildDocumentGroundingConfig(config)`](#builddocumentgroundingconfigconfig) + - [`buildTranslationConfig(type, config)`](#buildtranslationconfigtype-config) +- [Response Formats](#response-formats) + - [Text Response](#text-response) + - [JSON Object Response](#json-object-response) + - [JSON Schema Response](#json-schema-response) +- [Environment Variables](#environment-variables) +- [Version Information](#version-information) + - [Dependencies](#dependencies) +- [Related Documentation](#related-documentation-1) ## Provider Factory Functions +> **Architecture Context:** For provider factory pattern implementation details, +> see [Architecture - Provider Pattern](./ARCHITECTURE.md#provider-pattern). + ### `createSAPAIProvider(options?)` -Creates an SAP AI Core provider instance with automatic OAuth2 authentication. +Creates an SAP AI Core provider instance. **Signature:** + ```typescript -async function createSAPAIProvider( - options?: SAPAIProviderSettings -): Promise +function createSAPAIProvider(options?: SAPAIProviderSettings): SAPAIProvider; ``` **Parameters:** + - `options` (optional): `SAPAIProviderSettings` - Configuration options -**Returns:** `Promise` - Configured provider instance +**Returns:** `SAPAIProvider` - Configured provider instance **Example:** + ```typescript -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - deploymentId: 'd65d81e7c077e583', - resourceGroup: 'default' +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +const provider = createSAPAIProvider({ + resourceGroup: "default", + deploymentId: "d65d81e7c077e583", }); -const model = provider('gpt-4o'); +const model = provider("gpt-4o"); ``` --- -### `createSAPAIProviderSync(options)` +### `sapai` -Creates an SAP AI Core provider instance synchronously using a pre-acquired token. +Default SAP AI provider instance with automatic configuration. + +**Type:** -**Signature:** ```typescript -function createSAPAIProviderSync( - options: Omit & { token: string } -): SAPAIProvider +const sapai: SAPAIProvider; ``` -**Parameters:** -- `options.token` (required): `string` - Pre-acquired OAuth2 access token -- `options.baseURL` (optional): `string` - Custom API base URL -- `options.deploymentId` (optional): `string` - SAP AI Core deployment ID -- `options.resourceGroup` (optional): `string` - Resource group name -- `options.completionPath` (optional): `string` - Custom completion endpoint path -- `options.headers` (optional): `Record` - Custom HTTP headers -- `options.fetch` (optional): `typeof fetch` - Custom fetch implementation -- `options.defaultSettings` (optional): `SAPAISettings` - Default model settings +**Description:** -**Returns:** `SAPAIProvider` - Configured provider instance +A pre-configured provider instance that uses automatic authentication from: + +- `AICORE_SERVICE_KEY` environment variable (local development) +- `VCAP_SERVICES` service binding (SAP BTP Cloud Foundry) -**Use Case:** When you manage OAuth2 tokens yourself or need synchronous initialization. +This is the quickest way to get started without explicit provider creation. **Example:** + +```typescript +import "dotenv/config"; // Load environment variables +import { sapai } from "@jerome-benoit/sap-ai-provider"; +import { generateText } from "ai"; +import { APICallError } from "@ai-sdk/provider"; + +try { + // Use directly without creating a provider + const result = await generateText({ + model: sapai("gpt-4o"), + prompt: "Explain quantum computing", + }); + + console.log(result.text); +} catch (error) { + if (error instanceof APICallError) { + console.error("API error:", error.message, "- Status:", error.statusCode); + } + throw error; +} +``` + +**When to use:** + +- ✅ Quick prototypes and simple applications +- ✅ Default configuration is sufficient +- ✅ No need for custom resource groups or deployment IDs + +**When to use `createSAPAIProvider()` instead:** + +- Need custom `resourceGroup` or `deploymentId` +- Want explicit configuration control +- Need multiple provider instances with different settings + +--- + +## Models + +> **Architecture Context:** For model integration and message conversion +> details, see [Architecture - Model Support](./ARCHITECTURE.md#model-support). + +### Supported Models + +The SAP AI Core Provider supports all models available through SAP AI Core's +Orchestration service via the `@sap-ai-sdk/orchestration` package. + +> **Note:** The models listed below are representative examples. Actual model +> availability depends on your SAP AI Core tenant configuration, region, and +> subscription. Refer to your SAP AI Core configuration or the +> [SAP AI Core documentation](https://help.sap.com/docs/ai-core) for the +> definitive list of models available in your environment. + +**About Model Availability:** + +This library re-exports the `ChatModel` type from `@sap-ai-sdk/orchestration`, +which is dynamically maintained by SAP AI SDK. The actual list of available +models depends on: + +- Your SAP AI Core tenant configuration +- Your region and subscription +- Currently deployed models in your environment + +**Representative Model Examples** (non-exhaustive): + +**OpenAI (Azure):** + +- `gpt-4o`, `gpt-4o-mini` - Latest GPT-4 with vision & tools (recommended) +- `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano` - Latest GPT-4 variants +- `o1`, `o3`, `o3-mini`, `o4-mini` - Reasoning models + +**Google Vertex AI:** + +- `gemini-2.0-flash`, `gemini-2.0-flash-lite` - Fast inference +- `gemini-2.5-flash`, `gemini-2.5-pro` - Latest Gemini +- ⚠️ **Important**: Gemini models support **only 1 tool per request** + +**Anthropic (AWS Bedrock):** + +- `anthropic--claude-3.5-sonnet`, `anthropic--claude-3.7-sonnet` - Enhanced + Claude 3 +- `anthropic--claude-4-sonnet`, `anthropic--claude-4-opus` - Latest Claude 4 + +**Amazon Bedrock:** + +- `amazon--nova-pro`, `amazon--nova-lite`, `amazon--nova-micro`, + `amazon--nova-premier` + +**Open Source (AI Core):** + +- `mistralai--mistral-large-instruct`, `mistralai--mistral-small-instruct` +- `meta--llama3.1-70b-instruct` +- `cohere--command-a-reasoning` + +**Discovering Available Models:** + +To list models available in your SAP AI Core tenant: + +```bash +# Get access token +export TOKEN=$(curl -X POST "https:///oauth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=" \ + -d "client_secret=" | jq -r '.access_token') + +# List deployments +curl "https:///v2/lm/deployments" \ + -H "Authorization: Bearer $TOKEN" \ + -H "AI-Resource-Group: default" | jq '.resources[].details.resources.backend_details.model.name' +``` + +Or use **SAP AI Launchpad UI**: + +1. Navigate to ML Operations → Deployments +2. Filter by "Orchestration" scenario +3. View available model configurations + +**See Also:** + +- [SAP AI Core Models Documentation](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/models-and-scenarios) +- [Model Capabilities Comparison](#model-capabilities-comparison) (below) + +**⚠️ Important Model Limitations:** + +- **Gemini models** (all versions): Support **only 1 tool per request**. For + applications requiring multiple tools, use OpenAI models (gpt-4o, gpt-4.1) or + Claude models instead. +- **Amazon models**: Do not support the `n` parameter (number of completions). +- See + [cURL API Testing Guide - Tool Calling](./CURL_API_TESTING_GUIDE.md#tool-calling-example) + for complete model capabilities comparison. + +### Model Capabilities Comparison + +Quick reference for choosing the right model for your use case: + +| Model Family | Tool Calling | Vision | Streaming | Max Tools | Max Tokens | Notes | +| --------------- | ------------ | ------ | --------- | ---------- | ---------- | ------------------------------- | +| **GPT-4o** | ✅ | ✅ | ✅ | Unlimited | 16,384 | **Recommended** - Full features | +| **GPT-4o-mini** | ✅ | ✅ | ✅ | Unlimited | 16,384 | Fast, cost-effective | +| **GPT-4.1** | ✅ | ✅ | ✅ | Unlimited | 16,384 | Latest GPT-4 | +| **Gemini 2.0** | ⚠️ | ✅ | ✅ | **1 only** | 32,768 | Tool limitation | +| **Gemini 1.5** | ⚠️ | ✅ | ✅ | **1 only** | 32,768 | Tool limitation | +| **Claude 3.5** | ✅ | ✅ | ✅ | Unlimited | 8,192 | High quality | +| **Claude 4** | ✅ | ✅ | ✅ | Unlimited | 8,192 | Latest Claude | +| **Amazon Nova** | ✅ | ✅ | ✅ | Unlimited | 8,192 | No `n` parameter support | +| **o1/o3** | ⚠️ | ❌ | ✅ | Limited | 16,384 | Reasoning models | +| **Llama 3.1** | ✅ | ❌ | ✅ | Unlimited | 8,192 | Open source | +| **Mistral** | ✅ | ⚠️ | ✅ | Unlimited | 8,192 | Pixtral has vision | + +**Legend:** + +- ✅ Fully supported +- ⚠️ Limited support (see notes) +- ❌ Not supported + +**Choosing a model:** + +- **Multiple tools required?** → Use GPT-4o, Claude, or Amazon Nova (avoid + Gemini) +- **Vision needed?** → Use GPT-4o, Gemini, Claude, or Pixtral +- **Cost-sensitive?** → Use GPT-4o-mini or Gemini Flash +- **Maximum context?** → Use Gemini (32k tokens) +- **Open source?** → Use Llama or Mistral + +### Model Selection Guide by Use Case + +Quick reference for selecting models based on your application requirements: + +| Use Case | Recommended Models | Avoid | Notes | +| ----------------------------- | --------------------------------------------- | ----------------------------- | ------------------------------------ | +| **Multi-tool applications** | GPT-4o, GPT-4.1, Claude 3.5+, Amazon Nova | Gemini (all versions) | Gemini limited to 1 tool per request | +| **Vision + multi-modal** | GPT-4o, GPT-4.1, Gemini 2.0, Claude 3.5+ | Llama, o1/o3 reasoning models | Best image understanding | +| **Cost-effective production** | GPT-4o-mini, Gemini 2.0 Flash, Claude 3 Haiku | GPT-4.1, Claude 4 Opus | Balance of quality and cost | +| **Long context (>8k tokens)** | Gemini 1.5/2.0 (32k), GPT-4o/4.1 (16k) | Older GPT-4, Amazon models | Check token limits | +| **Reasoning-heavy tasks** | o1, o3, Claude 4 Opus, GPT-4.1 | Fast/Mini variants | Slower but higher quality | +| **Real-time streaming** | GPT-4o-mini, Gemini Flash, Claude Haiku | o1/o3 reasoning models | Optimized for low latency | +| **Open-source/self-hosted** | Llama 3.1, Mistral Large | Proprietary models | Deployment flexibility | +| **Enterprise compliance** | Amazon Nova, Claude 4, GPT-4.1 | Community models | Better audit trails | + +### Performance vs Quality Trade-offs + +| Model Tier | Speed | Quality | Cost | Best For | +| ---------------------------------------------------- | -------- | ---------- | -------- | -------------------------------- | +| **Nano/Micro** (GPT-4.1-nano, Nova-micro) | ⚡⚡⚡⚡ | ⭐⭐ | 💰 | Simple classification, keywords | +| **Mini/Lite** (GPT-4o-mini, Gemini Flash, Nova-lite) | ⚡⚡⚡ | ⭐⭐⭐ | 💰💰 | Production apps, chat, summaries | +| **Standard** (GPT-4o, Claude 3.5, Gemini Pro) | ⚡⚡ | ⭐⭐⭐⭐ | 💰💰💰 | Complex reasoning, analysis | +| **Premium** (Claude 4 Opus, GPT-4.1, o3) | ⚡ | ⭐⭐⭐⭐⭐ | 💰💰💰💰 | Research, critical decisions | + +--- + +## Tool Calling (Function Calling) + +Tool calling enables AI models to invoke functions and use external tools during +text generation. This is essential for building agentic AI applications that can +perform actions like database queries, API calls, calculations, or data +retrieval. + +### Overview + +When you provide tools to the model, it can decide to call one or more tools +based on the conversation context. The provider handles: + +- Converting tool definitions to SAP AI Core format +- Parsing tool call responses from the AI model +- Managing multi-turn conversations with tool results +- Handling parallel tool calls (model-dependent) + +### Basic Tool Calling Example + +```typescript +import { generateText } from "ai"; +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +import { z } from "zod"; + +const provider = createSAPAIProvider(); + +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "What's the weather in Tokyo and 5+3?", + tools: { + getWeather: { + description: "Get weather for a city", + parameters: z.object({ + city: z.string().describe("City name"), + }), + execute: async ({ city }) => { + // Your implementation + return { temp: 72, conditions: "sunny" }; + }, + }, + calculator: { + description: "Perform calculations", + parameters: z.object({ + expression: z.string().describe("Math expression"), + }), + execute: async ({ expression }) => { + return { result: eval(expression) }; + }, + }, + }, +}); + +console.log(result.text); // "It's sunny and 72°F in Tokyo. 5+3 equals 8." +console.log(result.toolCalls); // Array of tool invocations +console.log(result.toolResults); // Array of tool results +``` + +### Model-Specific Tool Limitations + +⚠️ **Important:** Not all models support tool calling equally: + +| Model Family | Tool Support | Max Tools | Parallel Calls | Notes | +| -------------- | ------------ | ---------- | -------------- | --------------------------------------- | +| GPT-4o/4.1 | ✅ Full | Unlimited | ✅ Yes | Recommended for multi-tool applications | +| GPT-4o-mini | ✅ Full | Unlimited | ✅ Yes | Cost-effective with full tool support | +| Claude 3.5/4 | ✅ Full | Unlimited | ✅ Yes | Excellent tool calling accuracy | +| Amazon Nova | ✅ Full | Unlimited | ✅ Yes | Full support across all Nova variants | +| **Gemini 1.5** | ⚠️ Limited | **1 only** | ❌ No | Single tool per request limitation | +| **Gemini 2.0** | ⚠️ Limited | **1 only** | ❌ No | Single tool per request limitation | +| Llama 3.1 | ✅ Full | Unlimited | ⚠️ Limited | Varies by deployment | +| Mistral | ✅ Full | Unlimited | ✅ Yes | Good tool calling support | +| o1/o3 | ⚠️ Limited | Limited | ❌ No | Reasoning models have tool restrictions | + +**Key Takeaway:** For applications requiring multiple tools, use **GPT-4o**, +**Claude**, or **Amazon Nova** models. Avoid Gemini for multi-tool scenarios. + +### Tool Definition Format + +Tools are defined using Zod schemas (recommended) or JSON Schema: + ```typescript -const token = await getMyOAuthToken(); -const provider = createSAPAIProviderSync({ - token, - deploymentId: 'my-deployment' +import { z } from "zod"; + +// Zod schema (recommended) +const weatherTool = { + description: "Get current weather for a location", + parameters: z.object({ + city: z.string().describe("City name"), + units: z.enum(["celsius", "fahrenheit"]).optional(), + }), + execute: async ({ city, units }) => { + // Implementation + }, +}; + +// JSON Schema (alternative) +const calculatorTool = { + type: "function", + function: { + name: "calculator", + description: "Perform mathematical calculations", + parameters: { + type: "object", + properties: { + expression: { + type: "string", + description: "Math expression to evaluate", + }, + }, + required: ["expression"], + }, + }, + execute: async (params) => { + // Implementation + }, +}; +``` + +### Parallel Tool Calls + +Some models (GPT-4o, Claude, Amazon Nova) can call multiple tools +simultaneously: + +```typescript +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "What's the weather in Tokyo, London, and Paris?", + tools: { getWeather }, + modelParams: { + parallel_tool_calls: true, // Enable parallel execution + }, +}); + +// Model can call getWeather 3 times in parallel +``` + +⚠️ **Important:** Set `parallel_tool_calls: false` when using Gemini models or +when tool execution order matters. + +### Multi-Turn Tool Conversations + +The AI SDK automatically handles multi-turn conversations when tools are +involved: + +```typescript +const result = await generateText({ + model: provider("gpt-4o"), + messages: [ + { role: "user", content: "Book a flight to Paris" }, + ], + tools: { + searchFlights: { + description: "Search for available flights", + parameters: z.object({ + destination: z.string(), + date: z.string(), + }), + execute: async ({ destination, date }) => { + return { flights: [...] }; + }, + }, + bookFlight: { + description: "Book a specific flight", + parameters: z.object({ + flightId: z.string(), + }), + execute: async ({ flightId }) => { + return { confirmation: "ABC123" }; + }, + }, + }, +}); + +// Conversation flow: +// 1. User: "Book a flight to Paris" +// 2. Model calls: searchFlights({ destination: "Paris", date: "..." }) +// 3. Model receives: { flights: [...] } +// 4. Model calls: bookFlight({ flightId: "..." }) +// 5. Model receives: { confirmation: "ABC123" } +// 6. Model responds: "Your flight is booked. Confirmation: ABC123" +``` + +### Error Handling with Tools + +Handle tool execution errors gracefully: + +```typescript +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "What's the weather?", + tools: { + getWeather: { + description: "Get weather", + parameters: z.object({ city: z.string() }), + execute: async ({ city }) => { + try { + const response = await fetch(`https://api.weather.com/${city}`); + if (!response.ok) { + throw new Error(`Weather API error: ${response.status}`); + } + return await response.json(); + } catch (error) { + // Return error message that the model can understand + return { + error: true, + message: `Failed to get weather: ${error.message}`, + }; + } + }, + }, + }, +}); +``` + +### Streaming with Tools + +Tool calls work with streaming responses: + +```typescript +const result = await streamText({ + model: provider("gpt-4o"), + prompt: "Calculate 5+3 and tell me about it", + tools: { calculator }, +}); + +for await (const part of result.textStream) { + process.stdout.write(part); // Stream text as it's generated +} + +console.log(result.toolCalls); // Available after stream completes +``` + +### Advanced: Tool Choice Control + +Control when the model should use tools: + +```typescript +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "What's 5+3?", + tools: { calculator }, + toolChoice: "required", // Force tool usage + // toolChoice: "auto" // (default) Let model decide + // toolChoice: "none" // Disable tools for this request }); ``` +### Best Practices + +1. **Model Selection:** Use GPT-4o, Claude, or Amazon Nova for multi-tool + applications +2. **Tool Descriptions:** Write clear, specific descriptions of what each tool + does +3. **Parameter Schemas:** Use descriptive field names and include descriptions +4. **Error Handling:** Return error objects that models can interpret, not just + throw exceptions +5. **Tool Naming:** Use camelCase names (e.g., `getWeather`, not `get_weather`) +6. **Parallel Calls:** Enable only when tool execution order doesn't matter +7. **Testing:** Test with Gemini to ensure your app works with the 1-tool + limitation + +### Related Documentation + +- [cURL API Testing Guide - Tool Calling Examples](./CURL_API_TESTING_GUIDE.md#tool-calling-example) - + Direct API testing +- [Architecture - Tool Calling Flow](./ARCHITECTURE.md#tool-calling-flow) - + Internal implementation details +- [Vercel AI SDK - Tool Calling Docs](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) - + Upstream documentation + --- -### `sapai` +## Embeddings -Default provider instance that uses the `SAP_AI_TOKEN` environment variable. +Generate vector embeddings for RAG (Retrieval-Augmented Generation), semantic +search, similarity matching, and clustering. -**Type:** `SAPAIProvider` +### Overview + +The SAP AI Provider implements the Vercel AI SDK's `EmbeddingModelV3` interface, +enabling you to generate embeddings using models available through SAP AI Core's +Orchestration service. + +Key features: + +- Full `EmbeddingModelV3` specification compliance +- Support for single and batch embedding generation +- Configurable embedding types (`document`, `query`, `text`) +- Automatic validation of batch sizes with `maxEmbeddingsPerCall` +- AbortSignal support for request cancellation + +### Basic Usage -**Usage:** ```typescript -import { sapai } from '@mymediset/sap-ai-provider'; +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +import { embed, embedMany } from "ai"; + +const provider = createSAPAIProvider(); + +// Single embedding +const { embedding } = await embed({ + model: provider.embedding("text-embedding-ada-002"), + value: "What is machine learning?", +}); + +console.log("Embedding dimensions:", embedding.length); -// Requires SAP_AI_TOKEN environment variable -const model = sapai('gpt-4o'); +// Multiple embeddings (batch) +const { embeddings } = await embedMany({ + model: provider.embedding("text-embedding-3-small"), + values: ["Hello world", "AI is transforming industries", "Vector databases"], +}); + +console.log("Generated", embeddings.length, "embeddings"); +``` + +### Embedding Settings + +Configure embedding behavior with `SAPAIEmbeddingSettings`: + +```typescript +const model = provider.embedding("text-embedding-3-large", { + // Maximum embeddings per API call (default: 2048) + maxEmbeddingsPerCall: 100, + + // Embedding type: "document", "query", or "text" (default: "text") + type: "document", + + // Model-specific parameters + modelParams: { + // Parameters passed to the embedding model + }, +}); ``` +**Embedding Types:** + +| Type | Use Case | Example | +| ---------- | ---------------------------------------- | ------------------------------- | +| `document` | Embedding documents for storage/indexing | RAG document ingestion | +| `query` | Embedding search queries | Semantic search queries | +| `text` | General-purpose text embedding (default) | Similarity matching, clustering | + +### SAPAIEmbeddingModel + +Implementation of Vercel AI SDK's `EmbeddingModelV3` interface. + +**Properties:** + +| Property | Type | Description | +| ---------------------- | -------- | ------------------------------------------------- | +| `specificationVersion` | `'v3'` | API specification version | +| `modelId` | `string` | Embedding model identifier | +| `provider` | `string` | Provider identifier (`'sap-ai.embedding'`) | +| `maxEmbeddingsPerCall` | `number` | Maximum values per `doEmbed` call (default: 2048) | + +**Methods:** + +#### `doEmbed(options)` + +Generate embeddings for an array of values. + +**Signature:** + +```typescript +async doEmbed(options: { + values: string[]; + abortSignal?: AbortSignal; +}): Promise<{ + embeddings: number[][]; +}> +``` + +**Parameters:** + +- `values`: Array of strings to embed +- `abortSignal`: Optional signal to cancel the request + +**Returns:** Object containing `embeddings` array (same order as input values) + +**Throws:** + +- `TooManyEmbeddingValuesForCallError` - When `values.length > maxEmbeddingsPerCall` +- `APICallError` - For API/HTTP errors + +**Example:** + +```typescript +const model = provider.embedding("text-embedding-ada-002"); + +// Direct model usage (advanced) +const result = await model.doEmbed({ + values: ["Hello", "World"], + abortSignal: controller.signal, +}); + +console.log(result.embeddings); // [[0.1, 0.2, ...], [0.3, 0.4, ...]] +``` + +### SAPAIEmbeddingSettings + +Configuration options for embedding models. + +**Properties:** + +| Property | Type | Default | Description | +| ---------------------- | ---------------------- | -------- | --------------------------- | +| `maxEmbeddingsPerCall` | `number` | `2048` | Maximum values per API call | +| `type` | `EmbeddingType` | `'text'` | Embedding type | +| `modelParams` | `EmbeddingModelParams` | - | Model-specific parameters | + +**EmbeddingType Values:** + +- `'document'` - For embedding documents (storage/indexing) +- `'query'` - For embedding search queries +- `'text'` - General-purpose embedding (default) + +### SAPAIEmbeddingModelId + +Type for embedding model identifiers. + +**Type:** + +```typescript +export type SAPAIEmbeddingModelId = string; +``` + +**Common Models:** + +| Model | Provider | Dimensions | Notes | +| ------------------------ | -------- | ---------- | ------------------------ | +| `text-embedding-ada-002` | OpenAI | 1536 | Cost-effective, reliable | +| `text-embedding-3-small` | OpenAI | 1536 | Balanced performance | +| `text-embedding-3-large` | OpenAI | 3072 | Highest quality | + +> **Note:** Model availability depends on your SAP AI Core tenant configuration, +> region, and subscription. + --- ## Interfaces ### `SAPAIProvider` -Main provider interface extending Vercel AI SDK's `ProviderV2`. +Main provider interface extending Vercel AI SDK's `ProviderV3`. **Properties:** + - None (function-based interface) **Methods:** @@ -111,31 +799,171 @@ Main provider interface extending Vercel AI SDK's `ProviderV2`. Create a language model instance. **Signature:** + ```typescript -(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAIChatLanguageModel +(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel ``` **Parameters:** + - `modelId`: Model identifier (e.g., 'gpt-4o', 'anthropic--claude-3.5-sonnet') - `settings`: Optional model configuration **Example:** + ```typescript -const model = provider('gpt-4o', { +const model = provider("gpt-4o", { modelParams: { temperature: 0.7, - maxTokens: 2000 - } + maxTokens: 2000, + }, }); ``` #### `provider.chat(modelId, settings?)` -Explicit method for creating chat models (equivalent to calling provider function). +Explicit method for creating chat models (equivalent to calling provider +function). + +**Signature:** + +```typescript +chat(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel +``` + +#### `provider.embedding(modelId, settings?)` + +Create an embedding model instance. + +**Signature:** + +```typescript +embedding(modelId: SAPAIEmbeddingModelId, settings?: SAPAIEmbeddingSettings): SAPAIEmbeddingModel +``` + +**Parameters:** + +- `modelId`: Embedding model identifier (e.g., 'text-embedding-ada-002') +- `settings`: Optional embedding model configuration + +**Example:** + +```typescript +const embeddingModel = provider.embedding("text-embedding-3-small", { + maxEmbeddingsPerCall: 100, + type: "document", +}); +``` + +#### `provider.textEmbeddingModel(modelId, settings?)` + +> **Deprecated:** Use `provider.embeddingModel()` instead. This method is +> provided for backward compatibility. + +Alias for `embeddingModel()` method. **Signature:** + +```typescript +textEmbeddingModel(modelId: SAPAIEmbeddingModelId, settings?: SAPAIEmbeddingSettings): SAPAIEmbeddingModel +``` + +#### `provider.languageModel(modelId, settings?)` + +ProviderV3-compliant method for creating language model instances. This is the +standard way to create language models in AI SDK v4+. + +**Signature:** + +```typescript +languageModel(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel +``` + +**Parameters:** + +- `modelId`: Model identifier (e.g., 'gpt-4o', 'anthropic--claude-3.5-sonnet') +- `settings`: Optional model configuration + +**Example:** + +```typescript +// Using the V3 standard method +const model = provider.languageModel("gpt-4o", { + modelParams: { temperature: 0.7 }, +}); + +// Equivalent to calling the provider directly +const model2 = provider("gpt-4o", { modelParams: { temperature: 0.7 } }); +``` + +#### `provider.embeddingModel(modelId, settings?)` + +ProviderV3-compliant method for creating embedding model instances. This is the +standard way to create embedding models in AI SDK v4+. + +**Signature:** + +```typescript +embeddingModel(modelId: SAPAIEmbeddingModelId, settings?: SAPAIEmbeddingSettings): SAPAIEmbeddingModel +``` + +**Parameters:** + +- `modelId`: Embedding model identifier (e.g., 'text-embedding-ada-002') +- `settings`: Optional embedding model configuration + +**Example:** + +```typescript +// Using the V3 standard method +const embeddingModel = provider.embeddingModel("text-embedding-3-small", { + maxEmbeddingsPerCall: 100, +}); + +// Equivalent to provider.embedding() +const embeddingModel2 = provider.embedding("text-embedding-3-small"); +``` + +#### `provider.imageModel(modelId)` + +ProviderV3-compliant method for creating image generation models. + +**Signature:** + ```typescript -chat(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAIChatLanguageModel +imageModel(modelId: string): never +``` + +**Behavior:** + +Always throws `NoSuchModelError` because SAP AI Core does not support image +generation models. + +**Example:** + +```typescript +import { NoSuchModelError } from "@ai-sdk/provider"; + +try { + const imageModel = provider.imageModel("dall-e-3"); +} catch (error) { + if (error instanceof NoSuchModelError) { + console.log("Image generation not supported by SAP AI Core"); + } +} +``` + +#### `provider.specificationVersion` + +The ProviderV3 specification version identifier. + +**Type:** `'v3'` + +**Example:** + +```typescript +const provider = createSAPAIProvider(); +console.log(provider.specificationVersion); // 'v3' ``` --- @@ -146,35 +974,64 @@ Configuration options for the SAP AI Provider. **Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `serviceKey` | `string \| SAPAIServiceKey` | - | SAP AI Core service key (JSON or object) | -| `token` | `string` | - | Direct OAuth2 access token | -| `deploymentId` | `string` | `'d65d81e7c077e583'` | SAP AI Core deployment ID | -| `resourceGroup` | `string` | `'default'` | Resource group for isolation | -| `baseURL` | `string` | Auto-detected | Custom API base URL | -| `completionPath` | `string` | `/inference/deployments/{id}/v2/completion` | Completion endpoint path | -| `headers` | `Record` | `{}` | Custom HTTP headers | -| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation | -| `defaultSettings` | `SAPAISettings` | - | Default model settings applied to all models | +| Property | Type | Default | Description | +| ----------------------- | ---------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | `'sap-ai'` | Provider name used as key in `providerOptions`/`providerMetadata`. Provider identifier uses `{name}.{type}` format (e.g., `"sap-ai.chat"`) | +| `resourceGroup` | `string` | `'default'` | SAP AI Core resource group | +| `deploymentId` | `string` | Auto | SAP AI Core deployment ID | +| `destination` | `HttpDestinationOrFetchOptions` | - | Custom destination configuration | +| `defaultSettings` | `SAPAISettings` | - | Default model settings applied to all models | +| `logLevel` | `'debug' \| 'error' \| 'info' \| 'warn'` | `'warn'` | Log level for SAP Cloud SDK internal logging (authentication, service binding). Can be overridden via `SAP_CLOUD_SDK_LOG_LEVEL` environment variable | +| `warnOnAmbiguousConfig` | `boolean` | `true` | Emit warnings for ambiguous configurations (e.g., when both `deploymentId` and `resourceGroup` are provided, `deploymentId` wins) | **Example:** + ```typescript const settings: SAPAIProviderSettings = { - serviceKey: process.env.SAP_AI_SERVICE_KEY, - deploymentId: 'custom-deployment', - resourceGroup: 'production', - headers: { - 'X-App-Version': '1.0.0' - }, + resourceGroup: "production", + deploymentId: "d65d81e7c077e583", + logLevel: "warn", // Suppress info messages (default) + warnOnAmbiguousConfig: true, // Warn if both deploymentId and resourceGroup provided defaultSettings: { modelParams: { - temperature: 0.7 - } - } + temperature: 0.7, + maxTokens: 2000, + }, + }, }; ``` +**Example with provider name:** + +```typescript +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +import { generateText } from "ai"; + +// Create provider with name +const provider = createSAPAIProvider({ + name: "sap-ai-core", + resourceGroup: "production", +}); + +// Provider identifier: "sap-ai-core.chat" +const model = provider("gpt-4o"); +console.log(model.provider); // => "sap-ai-core.chat" + +// Use provider name in providerOptions +const result = await generateText({ + model, + prompt: "Hello", + providerOptions: { + "sap-ai-core": { + includeReasoning: true, + }, + }, +}); + +// providerMetadata also uses provider name as key +console.log(result.providerMetadata?.["sap-ai-core"]); +``` + --- ### `SAPAISettings` @@ -183,19 +1040,21 @@ Model-specific configuration options. **Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `modelVersion` | `string` | `'latest'` | Specific model version | -| `modelParams` | `ModelParams` | - | Model generation parameters | -| `safePrompt` | `boolean` | `true` | Enable safe prompt filtering | -| `structuredOutputs` | `boolean` | `false` | Enable structured output format | -| `masking` | `MaskingModuleConfig` | - | Data masking configuration (DPI) | -| `responseFormat` | `ResponseFormatConfig` | - | Response format specification | +| Property | Type | Default | Description | +| ------------------ | ---------------------- | ---------- | ------------------------------------------------------------------------------------------------------ | +| `modelVersion` | `string` | `'latest'` | Specific model version | +| `includeReasoning` | `boolean` | - | Whether to include assistant reasoning parts in SAP prompt conversion (may contain internal reasoning) | +| `modelParams` | `ModelParams` | - | Model generation parameters | +| `masking` | `MaskingModule` | - | Data masking configuration (DPI) | +| `filtering` | `FilteringModule` | - | Content filtering configuration | +| `responseFormat` | `ResponseFormatConfig` | - | Response format specification | +| `tools` | `ChatCompletionTool[]` | - | Tool definitions in SAP AI SDK format | **Example:** + ```typescript const settings: SAPAISettings = { - modelVersion: 'latest', + modelVersion: "latest", modelParams: { temperature: 0.3, maxTokens: 2000, @@ -203,115 +1062,268 @@ const settings: SAPAISettings = { frequencyPenalty: 0.1, presencePenalty: 0.0, n: 1, - parallel_tool_calls: true + parallel_tool_calls: true, + }, + tools: [ + { + type: "function", + function: { + name: "calculator", + description: "Perform calculations", + parameters: { + /* JSON Schema */ + }, + }, + }, + ], +}; +``` + +--- + +### `ModelParams` + +Fine-grained model behavior parameters. + +> **Note:** Many parameters are model/provider-specific. Some models may ignore +> or only partially support certain options (e.g., Gemini tool calls +> limitations, Amazon models not supporting `n`). Always consult the model's +> upstream documentation. + +**Properties:** + +| Property | Type | Range | Default | Description | +| --------------------- | --------- | ------- | -------------- | ------------------------------------------------------ | +| `maxTokens` | `number` | 1-4096+ | `1000` | Maximum tokens to generate | +| `temperature` | `number` | 0-2 | Model-specific | Sampling temperature | +| `topP` | `number` | 0-1 | `1` | Nucleus sampling parameter | +| `frequencyPenalty` | `number` | -2 to 2 | `0` | Frequency penalty | +| `presencePenalty` | `number` | -2 to 2 | `0` | Presence penalty | +| `n` | `number` | 1-10 | `1` | Number of completions (not supported by Amazon models) | +| `parallel_tool_calls` | `boolean` | - | Model-specific | Enable parallel tool execution (OpenAI models) | + +--- + +### `SAPAIServiceKey` + +SAP BTP service key structure. + +> **Note:** In v2.0+, the service key is provided via the `AICORE_SERVICE_KEY` +> environment variable (as a JSON string), not as a parameter to +> `createSAPAIProvider()`. + +**Properties:** + +| Property | Type | Required | Description | +| ----------------- | ------------------------ | -------- | ----------------------------------------------- | +| `serviceurls` | `{ AI_API_URL: string }` | Yes | Service URLs configuration | +| `clientid` | `string` | Yes | OAuth2 client ID | +| `clientsecret` | `string` | Yes | OAuth2 client secret | +| `url` | `string` | Yes | OAuth2 authorization server URL | +| `identityzone` | `string` | No | Identity zone for multi-tenant environments | +| `identityzoneid` | `string` | No | Unique identifier for the identity zone | +| `appname` | `string` | No | Application name in SAP BTP | +| `credential-type` | `string` | No | Type of credential (typically "binding-secret") | + +**For setup instructions and examples, see +[Environment Setup Guide](./ENVIRONMENT_SETUP.md).** + +--- + +### `MaskingModuleConfig` + +Data masking configuration using SAP Data Privacy Integration (DPI). + +**Properties:** + +| Property | Type | Description | +| ------------------- | ------------------------- | --------------------------------- | +| `masking_providers` | `MaskingProviderConfig[]` | List of masking service providers | + +--- + +### `DpiConfig` + +SAP Data Privacy Integration masking configuration. + +**Properties:** + +| Property | Type | Description | +| ---------------------- | --------------------------------------- | -------------------------------------- | +| `type` | `'sap_data_privacy_integration'` | Provider type | +| `method` | `'anonymization' \| 'pseudonymization'` | Masking method | +| `entities` | `DpiEntityConfig[]` | Entities to mask | +| `allowlist` | `string[]` | Strings that should not be masked | +| `mask_grounding_input` | `{ enabled?: boolean }` | Whether to mask grounding module input | + +**Example:** + +```typescript +const masking: MaskingModuleConfig = { + masking_providers: [ + { + type: "sap_data_privacy_integration", + method: "anonymization", + entities: [ + { + type: "profile-email", + replacement_strategy: { method: "fabricated_data" }, + }, + { + type: "profile-person", + replacement_strategy: { method: "constant", value: "REDACTED" }, + }, + { + regex: "\\b[0-9]{4}-[0-9]{4}-[0-9]{3,5}\\b", + replacement_strategy: { method: "constant", value: "ID_REDACTED" }, + }, + ], + allowlist: ["SAP", "BTP"], + }, + ], +}; +``` + +--- + +## Provider Options + +Provider options enable per-call configuration that overrides constructor settings. +These options are passed via `providerOptions['sap-ai']` in AI SDK calls and are +validated at runtime using Zod schemas. + +### SAP AI Provider Name Constant + +The default provider name constant. Use as key in `providerOptions` and `providerMetadata`. + +**Value:** `"sap-ai"` + +**Usage:** + +```typescript +import { SAP_AI_PROVIDER_NAME } from "@jerome-benoit/sap-ai-provider"; + +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "Hello", + providerOptions: { + [SAP_AI_PROVIDER_NAME]: { + includeReasoning: true, + }, }, - safePrompt: true, - structuredOutputs: true -}; +}); ``` --- -### `ModelParams` +### `sapAILanguageModelProviderOptions` -Fine-grained model behavior parameters. +Zod schema for validating language model provider options. -**Properties:** +**Validated Fields:** + +| Field | Type | Description | +| --------------------------------- | ------------------ | --------------------------------------------------- | +| `includeReasoning` | `boolean` | Whether to include assistant reasoning in responses | +| `modelParams.temperature` | `number (0-2)` | Sampling temperature | +| `modelParams.maxTokens` | `positive integer` | Maximum tokens to generate | +| `modelParams.topP` | `number (0-1)` | Nucleus sampling parameter | +| `modelParams.frequencyPenalty` | `number (-2 to 2)` | Frequency penalty | +| `modelParams.presencePenalty` | `number (-2 to 2)` | Presence penalty | +| `modelParams.n` | `positive integer` | Number of completions | +| `modelParams.parallel_tool_calls` | `boolean` | Enable parallel tool calls | + +**Example:** -| Property | Type | Range | Default | Description | -|----------|------|-------|---------|-------------| -| `maxTokens` | `number` | 1-4096+ | `1000` | Maximum tokens to generate | -| `temperature` | `number` | 0-2 | Model-specific | Sampling temperature | -| `topP` | `number` | 0-1 | `1` | Nucleus sampling parameter | -| `frequencyPenalty` | `number` | -2 to 2 | `0` | Frequency penalty | -| `presencePenalty` | `number` | -2 to 2 | `0` | Presence penalty | -| `n` | `number` | 1-10 | `1` | Number of completions (not supported by Amazon models) | -| `parallel_tool_calls` | `boolean` | - | Model-specific | Enable parallel tool execution (OpenAI models) | +```typescript +import { generateText } from "ai"; +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +const provider = createSAPAIProvider(); + +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "Explain quantum computing", + providerOptions: { + "sap-ai": { + includeReasoning: true, + modelParams: { + temperature: 0.7, + maxTokens: 1000, + }, + }, + }, +}); +``` --- -### `SAPAIServiceKey` +### `sapAIEmbeddingProviderOptions` -SAP BTP service key structure. +Zod schema for validating embedding model provider options. -**Properties:** +**Validated Fields:** -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `serviceurls` | `{ AI_API_URL: string }` | Yes | Service URLs configuration | -| `clientid` | `string` | Yes | OAuth2 client ID | -| `clientsecret` | `string` | Yes | OAuth2 client secret | -| `url` | `string` | Yes | OAuth2 authorization server URL | -| `identityzone` | `string` | No | Identity zone for multi-tenant environments | -| `identityzoneid` | `string` | No | Unique identifier for the identity zone | -| `appname` | `string` | No | Application name in SAP BTP | -| `credential-type` | `string` | No | Type of credential (typically "binding-secret") | +| Field | Type | Description | +| ------------- | --------------------------------- | --------------------------- | +| `type` | `"text" \| "query" \| "document"` | Embedding task type | +| `modelParams` | `Record` | Additional model parameters | **Example:** + ```typescript -const serviceKey: SAPAIServiceKey = { - serviceurls: { - AI_API_URL: "https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com" +import { embed } from "ai"; +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +const provider = createSAPAIProvider(); + +const { embedding } = await embed({ + model: provider.embedding("text-embedding-ada-002"), + value: "Search query text", + providerOptions: { + "sap-ai": { + type: "query", + }, }, - clientid: "sb-...", - clientsecret: "...", - url: "https://....authentication.eu10.hana.ondemand.com", - identityzone: "...", - appname: "my-app", - "credential-type": "binding-secret" -}; +}); ``` --- -### `MaskingModuleConfig` +### `SAPAILanguageModelProviderOptions` (Type) -Data masking configuration using SAP Data Privacy Integration (DPI). +TypeScript type inferred from the Zod schema for language model options. -**Properties:** +**Type:** -| Property | Type | Description | -|----------|------|-------------| -| `masking_providers` | `MaskingProviderConfig[]` | List of masking service providers | +```typescript +type SAPAILanguageModelProviderOptions = { + includeReasoning?: boolean; + modelParams?: { + frequencyPenalty?: number; + maxTokens?: number; + n?: number; + parallel_tool_calls?: boolean; + presencePenalty?: number; + temperature?: number; + topP?: number; + [key: string]: unknown; // Passthrough for custom params + }; +}; +``` --- -### `DpiConfig` - -SAP Data Privacy Integration masking configuration. +### `SAPAIEmbeddingProviderOptions` (Type) -**Properties:** +TypeScript type inferred from the Zod schema for embedding model options. -| Property | Type | Description | -|----------|------|-------------| -| `type` | `'sap_data_privacy_integration'` | Provider type | -| `method` | `'anonymization' \| 'pseudonymization'` | Masking method | -| `entities` | `DpiEntityConfig[]` | Entities to mask | -| `allowlist` | `string[]` | Strings that should not be masked | -| `mask_grounding_input` | `{ enabled?: boolean }` | Whether to mask grounding module input | +**Type:** -**Example:** ```typescript -const masking: MaskingModuleConfig = { - masking_providers: [{ - type: 'sap_data_privacy_integration', - method: 'anonymization', - entities: [ - { - type: 'profile-email', - replacement_strategy: { method: 'fabricated_data' } - }, - { - type: 'profile-person', - replacement_strategy: { method: 'constant', value: 'REDACTED' } - }, - { - regex: '\\b[0-9]{4}-[0-9]{4}-[0-9]{3,5}\\b', - replacement_strategy: { method: 'constant', value: 'ID_REDACTED' } - } - ], - allowlist: ['SAP', 'BTP'] - }] +type SAPAIEmbeddingProviderOptions = { + type?: "text" | "query" | "document"; + modelParams?: Record; }; ``` @@ -321,30 +1333,27 @@ const masking: MaskingModuleConfig = { ### `SAPAIModelId` -Supported model identifiers. +Model identifier type for SAP AI Core models. **Type:** + ```typescript -type SAPAIModelId = - | "gpt-4" | "gpt-4o" | "gpt-4o-mini" - | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" - | "o1" | "o1-mini" | "o3" | "o3-mini" | "o4-mini" - | "gemini-1.5-pro" | "gemini-1.5-flash" - | "gemini-2.0-pro" | "gemini-2.0-flash" | "gemini-2.0-flash-thinking" | "gemini-2.0-flash-lite" - | "gemini-2.5-pro" | "gemini-2.5-flash" - | "anthropic--claude-3-haiku" | "anthropic--claude-3-sonnet" | "anthropic--claude-3-opus" - | "anthropic--claude-3.5-sonnet" | "anthropic--claude-3.7-sonnet" - | "anthropic--claude-4-sonnet" | "anthropic--claude-4-opus" - | "amazon--nova-premier" | "amazon--nova-pro" | "amazon--nova-lite" | "amazon--nova-micro" - | "amazon--titan-text-lite" | "amazon--titan-text-express" - | "meta--llama3-70b-instruct" | "meta--llama3.1-70b-instruct" - | "mistralai--mistral-large-instruct" | "mistralai--mistral-small-instruct" - | "mistralai--pixtral-large-instruct" - | "ibm--granite-13b-chat" - | "alephalpha-pharia-1-7b-control" - | (string & {}); // Allow custom model IDs +export type SAPAIModelId = ChatModel; // Re-exported from @sap-ai-sdk/orchestration ``` +**Description:** + +Re-exports the `ChatModel` type from `@sap-ai-sdk/orchestration`, which is +dynamically maintained by SAP AI SDK. + +**For complete model information, see the [Models](#models) section above**, +including: + +- Available model list (OpenAI, Google, Anthropic, Amazon, Open Source) +- Model capabilities comparison +- Selection guide by use case +- Performance trade-offs + --- ### `DpiEntities` @@ -352,6 +1361,7 @@ type SAPAIModelId = Standard entity types recognized by SAP DPI. **Available Types:** + - `profile-person` - Person names - `profile-org` - Organization names - `profile-location` - Locations @@ -372,20 +1382,20 @@ Standard entity types recognized by SAP DPI. ## Classes -### `SAPAIChatLanguageModel` +### `SAPAILanguageModel` -Implementation of Vercel AI SDK's `LanguageModelV2` interface. +Implementation of Vercel AI SDK's `LanguageModelV3` interface. **Properties:** -| Property | Type | Description | -|----------|------|-------------| -| `specificationVersion` | `'v2'` | API specification version | -| `defaultObjectGenerationMode` | `'json'` | Default object generation mode | -| `supportsImageUrls` | `true` | Image URL support flag | -| `supportsStructuredOutputs` | `true` | Structured output support | -| `modelId` | `SAPAIModelId` | Current model identifier | -| `provider` | `string` | Provider name ('sap-ai') | +| Property | Type | Description | +| ----------------------------- | -------------- | ------------------------------------- | +| `specificationVersion` | `'v3'` | API specification version | +| `defaultObjectGenerationMode` | `'json'` | Default object generation mode | +| `supportsImageUrls` | `true` | Image URL support flag | +| `supportsStructuredOutputs` | `true` | Structured output support | +| `modelId` | `SAPAIModelId` | Current model identifier | +| `provider` | `string` | Provider identifier (`'sap-ai.chat'`) | **Methods:** @@ -394,24 +1404,24 @@ Implementation of Vercel AI SDK's `LanguageModelV2` interface. Generate a single completion (non-streaming). **Signature:** + ```typescript async doGenerate( - options: LanguageModelV2CallOptions + options: LanguageModelV3CallOptions ): Promise<{ - content: LanguageModelV2Content[]; - finishReason: LanguageModelV2FinishReason; - usage: LanguageModelV2Usage; + content: LanguageModelV3Content[]; + finishReason: LanguageModelV3FinishReason; + usage: LanguageModelV3Usage; rawCall: { rawPrompt: unknown; rawSettings: Record }; - warnings: LanguageModelV2CallWarning[]; + warnings: LanguageModelV3CallWarning[]; }> ``` **Example:** + ```typescript const result = await model.doGenerate({ - prompt: [ - { role: 'user', content: [{ type: 'text', text: 'Hello!' }] } - ] + prompt: [{ role: "user", content: [{ type: "text", text: "Hello!" }] }], }); ``` @@ -420,96 +1430,524 @@ const result = await model.doGenerate({ Generate a streaming completion. **Signature:** + ```typescript async doStream( - options: LanguageModelV2CallOptions + options: LanguageModelV3CallOptions ): Promise<{ - stream: ReadableStream; + stream: ReadableStream; rawCall: { rawPrompt: unknown; rawSettings: Record }; }> ``` +**Stream Events:** + +The stream emits the following event types in order: + +| Event Type | Description | When Emitted | +| ------------------- | ------------------------------------------------ | ----------------------------------- | +| `stream-start` | Stream initialization with warnings | First, before any content | +| `response-metadata` | Model ID, timestamp, and response ID | After first chunk received | +| `text-start` | Text block begins (includes unique block ID) | When text generation starts | +| `text-delta` | Incremental text chunk | For each text token | +| `text-end` | Text block completes | When text generation ends | +| `tool-input-start` | Tool input begins (includes tool ID and name) | When tool call starts | +| `tool-input-delta` | Incremental tool arguments | For each tool argument chunk | +| `tool-input-end` | Tool input completes | When tool arguments complete | +| `tool-call` | Complete tool call with ID, name, and full input | After tool-input-end | +| `finish` | Stream completes with usage and finish reason | Last event on success | +| `error` | Error occurred during streaming | On error (stream then closes) | +| `raw` | Raw SDK chunk (when `includeRawChunks: true`) | For each chunk, before other events | + +**Raw Chunks Option:** + +When `includeRawChunks: true` is passed in options, the stream will emit +additional `raw` events containing the unprocessed SDK response chunks. This is +useful for debugging or accessing provider-specific data not exposed through +standard events. + +```typescript +const { stream } = await model.doStream({ + prompt: [...], + includeRawChunks: true, +}); + +for await (const part of stream) { + if (part.type === "raw") { + console.log("Raw chunk:", part.rawValue); + } +} +``` + **Example:** + ```typescript const { stream } = await model.doStream({ prompt: [ - { role: 'user', content: [{ type: 'text', text: 'Write a story' }] } - ] + { + role: "user", + content: [{ type: "text", text: "Write a story" }], + }, + ], }); + +for await (const part of stream) { + switch (part.type) { + case "text-delta": + process.stdout.write(part.delta); + break; + case "tool-call": + console.log(`Tool called: ${part.toolName}`, part.input); + break; + case "finish": + console.log("Usage:", part.usage); + break; + case "error": + console.error("Stream error:", part.error); + break; + } +} ``` +> **Note:** See [Known Limitations](./TROUBLESHOOTING.md#known-limitations) for +> information about client-generated response IDs in streaming mode. + --- -### `SAPAIError` +### Error Handling & Reference -Custom error class for SAP AI Core errors. +> **Architecture Details:** For internal error conversion logic and retry +> mechanisms, see +> [Architecture - Error Handling](./ARCHITECTURE.md#error-handling). -**Properties:** +The provider uses standard Vercel AI SDK error types for consistent error +handling across providers. -| Property | Type | Description | -|----------|------|-------------| -| `code` | `number?` | HTTP status or error code | -| `location` | `string?` | Where the error occurred | -| `requestId` | `string?` | Request ID for tracking | -| `details` | `string?` | Additional error context | -| `intermediateResults` | `unknown?` | Intermediate results (v2 only) | -| `data` | `SAPAIErrorData?` | Raw error data from API | -| `response` | `Response?` | Original HTTP response | +#### Error Types + +**`APICallError`** - Thrown for HTTP/API errors (from `@ai-sdk/provider`) + +Properties: + +- `message`: Error description with helpful context +- `statusCode`: HTTP status code (401, 403, 429, 500, etc.) +- `url`: Request URL +- `requestBodyValues`: Request body (for debugging) +- `responseHeaders`: Response headers +- `responseBody`: Raw response body (contains SAP error details) +- `isRetryable`: Whether the error can be retried (true for 429, 5xx) + +**`LoadAPIKeyError`** - Thrown for authentication/configuration errors (from +`@ai-sdk/provider`) + +Properties: + +- `message`: Error description with setup instructions + +#### SAP-Specific Error Details + +SAP AI Core error details are preserved in `APICallError.responseBody` as JSON: -**Example:** ```typescript +{ + error: { + message: string; + code?: number; + location?: string; + request_id?: string; + } +} +``` + +#### Error Handling Examples + +```typescript +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; + try { - await generateText({ model, prompt: 'Hello' }); + const result = await generateText({ + model: provider("gpt-4o"), + prompt: "Hello", + }); } catch (error) { - if (error instanceof SAPAIError) { - console.error('Error Code:', error.code); - console.error('Request ID:', error.requestId); - console.error('Location:', error.location); - console.error('Details:', error.details); + if (error instanceof LoadAPIKeyError) { + // 401/403: Authentication/permission issue + console.error("Setup error:", error.message); + // Check AICORE_SERVICE_KEY environment variable + } else if (error instanceof NoSuchModelError) { + // 404: Model or deployment not found + console.error("Model not found:", error.modelId); + } else if (error instanceof APICallError) { + // Other API/HTTP errors (400, 429, 5xx, etc.) + console.error("API error:", error.message); + console.error("Status:", error.statusCode); + console.error("Retryable:", error.isRetryable); + + // Parse SAP error details + try { + const sapError = JSON.parse(error.responseBody); + console.error("SAP Error Code:", sapError.error.code); + console.error("Location:", sapError.error.location); + console.error("Request ID:", sapError.error.request_id); + } catch {} } } ``` +#### HTTP Status Code Reference + +Complete reference for status codes returned by SAP AI Core: + +| Code | Description | Error Type | Auto-Retry | Common Causes | Recommended Action | Guide | +| :--: | :-------------------- | :----------------- | :--------: | :----------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------- | +| 400 | Bad Request | `APICallError` | ❌ | Invalid parameters | Validate configuration against TypeScript types | [→ Guide](./TROUBLESHOOTING.md#problem-400-bad-request) | +| 401 | Unauthorized | `LoadAPIKeyError` | ❌ | Invalid/expired credentials | Check `AICORE_SERVICE_KEY` environment variable | [→ Guide](./TROUBLESHOOTING.md#problem-authentication-failed-or-401-errors) | +| 403 | Forbidden | `LoadAPIKeyError` | ❌ | Insufficient permissions | Verify service key has required roles | [→ Guide](./TROUBLESHOOTING.md#problem-403-forbidden) | +| 404 | Not Found | `NoSuchModelError` | ❌ | Invalid model ID or deployment | Verify deployment ID and model name | [→ Guide](./TROUBLESHOOTING.md#problem-404-modeldeployment-not-found) | +| 408 | Request Timeout | `APICallError` | ✅ | Request took too long | Automatic retry | [→ Guide](./TROUBLESHOOTING.md#problem-500502503504-server-errors) | +| 409 | Conflict | `APICallError` | ✅ | Transient conflict | Automatic retry | [→ Guide](./TROUBLESHOOTING.md#problem-500502503504-server-errors) | +| 429 | Too Many Requests | `APICallError` | ✅ | Rate limit exceeded | Automatic exponential backoff | [→ Guide](./TROUBLESHOOTING.md#problem-429-rate-limit-exceeded) | +| 500 | Internal Server Error | `APICallError` | ✅ | Service issue | Automatic retry, check SAP AI Core status | [→ Guide](./TROUBLESHOOTING.md#problem-500502503504-server-errors) | +| 502 | Bad Gateway | `APICallError` | ✅ | Network/proxy issue | Automatic retry | [→ Guide](./TROUBLESHOOTING.md#problem-500502503504-server-errors) | +| 503 | Service Unavailable | `APICallError` | ✅ | Service temporarily down | Automatic retry | [→ Guide](./TROUBLESHOOTING.md#problem-500502503504-server-errors) | +| 504 | Gateway Timeout | `APICallError` | ✅ | Request timeout | Automatic retry, reduce request complexity | [→ Guide](./TROUBLESHOOTING.md#problem-500502503504-server-errors) | + +#### Error Handling Strategy + +The provider automatically handles retryable errors (408, 409, 429, 5xx) with +exponential backoff. For non-retryable errors, your application should handle +them appropriately. + +**See also:** [Troubleshooting Guide](./TROUBLESHOOTING.md) for detailed solutions +to each error type. + +--- + +### `OrchestrationErrorResponse` + +Type representing SAP AI SDK error response structure (for advanced usage). + +**Type:** + +```typescript +type OrchestrationErrorResponse = { + error: + | { + message: string; + code?: number; + location?: string; + request_id?: string; + } + | Array<{ + message: string; + code?: number; + location?: string; + request_id?: string; + }>; +}; +``` + +This type is primarily used internally for error conversion but is exported for +advanced use cases. + --- ## Utility Functions -### `convertToSAPMessages(prompt)` +> **Architecture Context:** For message transformation flow and format details, +> see [Architecture - Message Conversion](./ARCHITECTURE.md#message-conversion). + +### `getProviderName(providerIdentifier)` + +Extracts the provider name from a provider identifier. + +Following the AI SDK convention, provider identifiers use the format +`{name}.{type}` (e.g., `"openai.chat"`, `"anthropic.messages"`). This +function extracts the provider name for use with `providerOptions` and +`providerMetadata`, which use the provider name as key. + +**Signature:** + +```typescript +function getProviderName(providerIdentifier: string): string; +``` + +**Parameters:** + +- `providerIdentifier`: The provider identifier (e.g., `"sap-ai.chat"`, + `"sap-ai.embedding"`) + +**Returns:** The provider name (e.g., `"sap-ai"`) + +**Example:** + +```typescript +import { getProviderName } from "@jerome-benoit/sap-ai-provider"; + +getProviderName("sap-ai.chat"); // => "sap-ai" +getProviderName("sap-ai-core.embedding"); // => "sap-ai-core" +getProviderName("sap-ai"); // => "sap-ai" (no type suffix) +``` + +**Use Case:** + +This function is useful when working with dynamic provider names or when you +need to access `providerMetadata` using the model's provider identifier: + +```typescript +import { createSAPAIProvider, getProviderName } from "@jerome-benoit/sap-ai-provider"; +import { generateText } from "ai"; + +const provider = createSAPAIProvider({ name: "my-sap" }); +const model = provider("gpt-4o"); + +const result = await generateText({ model, prompt: "Hello" }); + +// Use getProviderName to access metadata with the correct key +const providerName = getProviderName(model.provider); // "my-sap" +const metadata = result.providerMetadata?.[providerName]; +``` + +--- + +### `buildDpiMaskingProvider(config)` + +Creates a DPI (Data Privacy Integration) masking provider configuration for +anonymizing or pseudonymizing sensitive data. + +**Signature:** + +```typescript +function buildDpiMaskingProvider(config: DpiMaskingConfig): DpiMaskingProviderConfig; +``` + +**Parameters:** + +- `config.method`: Masking method - `"anonymization"` or `"pseudonymization"` +- `config.entities`: Array of entity types to mask (strings or objects with + replacement strategies) + +**Returns:** DPI masking provider configuration object + +**Example:** + +**Complete example:** +[examples/example-data-masking.ts](./examples/example-data-masking.ts) + +```typescript +const dpiMasking = buildDpiMaskingProvider({ + method: "anonymization", + entities: [ + "profile-email", + "profile-person", + { + type: "profile-phone", + replacement_strategy: { method: "constant", value: "REDACTED" }, + }, + ], +}); + +const provider = createSAPAIProvider({ + defaultSettings: { + masking: { + masking_providers: [dpiMasking], + }, + }, +}); +``` + +**Run it:** `npx tsx examples/example-data-masking.ts` + +--- + +### `buildAzureContentSafetyFilter(type, config?)` + +Creates an Azure Content Safety filter configuration for input or output content +filtering. + +**Signature:** + +```typescript +function buildAzureContentSafetyFilter(type: "input" | "output", config?: AzureContentSafetyFilterParameters): AzureContentSafetyFilterReturnType; +``` + +**Parameters:** + +- `type`: Filter type - `"input"` (before model) or `"output"` (after model) +- `config`: Optional safety levels for each category (default: `ALLOW_SAFE_LOW` + for all) + - `hate`: Hate speech filter level + - `violence`: Violence content filter level + - `selfHarm`: Self-harm content filter level + - `sexual`: Sexual content filter level + +**Filter Levels:** `ALLOW_SAFE`, `ALLOW_SAFE_LOW`, `ALLOW_SAFE_LOW_MEDIUM`, or +block all + +**Returns:** Azure Content Safety filter configuration + +**Example:** + +```typescript +const provider = createSAPAIProvider({ + defaultSettings: { + filtering: { + input: { + filters: [ + buildAzureContentSafetyFilter("input", { + hate: "ALLOW_SAFE", + violence: "ALLOW_SAFE_LOW_MEDIUM", + selfHarm: "ALLOW_SAFE", + sexual: "ALLOW_SAFE", + }), + ], + }, + }, + }, +}); +``` + +--- + +### `buildLlamaGuard38BFilter(type, categories)` + +Creates a Llama Guard 3 8B filter configuration for content safety filtering. + +**Signature:** + +```typescript +function buildLlamaGuard38BFilter(type: "input" | "output", categories: [LlamaGuard38BCategory, ...LlamaGuard38BCategory[]]): LlamaGuard38BFilterReturnType; +``` + +**Parameters:** + +- `type`: Filter type - `"input"` or `"output"` +- `categories`: Array of at least one category to filter (e.g., `"hate"`, + `"violence"`, `"elections"`) -Converts Vercel AI SDK prompt format to SAP AI Core message format. +**Returns:** Llama Guard 3 8B filter configuration + +**Example:** + +```typescript +const provider = createSAPAIProvider({ + defaultSettings: { + filtering: { + input: { + filters: [buildLlamaGuard38BFilter("input", ["hate", "violence"])], + }, + }, + }, +}); +``` + +--- + +### `buildDocumentGroundingConfig(config)` + +Creates a document grounding configuration for retrieval-augmented generation +(RAG). **Signature:** + ```typescript -function convertToSAPMessages( - prompt: LanguageModelV2Prompt -): SAPMessage[] +function buildDocumentGroundingConfig(config: DocumentGroundingServiceConfig): GroundingModule; ``` **Parameters:** -- `prompt`: Vercel AI SDK prompt array -**Returns:** SAP AI Core compatible message array +- `config`: Document grounding service configuration + +**Returns:** Full grounding module configuration + +**Example:** + +**Complete example:** +[examples/example-document-grounding.ts](./examples/example-document-grounding.ts) + +```typescript +const groundingConfig = buildDocumentGroundingConfig({ + filters: [ + { + id: "vector-store-1", // Your vector database ID + data_repositories: ["*"], // Search all repositories + }, + ], + placeholders: { + input: ["?question"], // Placeholder for user question + output: "groundingOutput", // Placeholder for grounding output + }, + metadata_params: ["file_name", "document_id"], // Optional metadata +}); + +const provider = createSAPAIProvider({ + defaultSettings: { + grounding: groundingConfig, + }, +}); + +// Now queries will be grounded in your documents +const model = provider("gpt-4o"); +``` + +**Run it:** `npx tsx examples/example-document-grounding.ts` + +--- + +### `buildTranslationConfig(type, config)` -**Supported Features:** -- Text messages (system, user, assistant) -- Multi-modal messages (text + images) -- Tool calls and tool results -- Conversation history +Creates a translation configuration for input/output translation using SAP +Document Translation service. -**Throws:** `UnsupportedFunctionalityError` for unsupported message types +**Signature:** + +```typescript +function buildTranslationConfig(type: "input" | "output", config: TranslationConfigParams): TranslationReturnType; +``` + +**Parameters:** + +- `type`: Translation type - `"input"` (before model) or `"output"` (after + model) +- `config`: Translation configuration + - `sourceLanguage`: Source language code (auto-detected if omitted) + - `targetLanguage`: Target language code (required) + - `translateMessagesHistory`: Whether to translate message history (optional) + +**Returns:** SAP Document Translation configuration **Example:** + +**Complete example:** +[examples/example-translation.ts](./examples/example-translation.ts) + ```typescript -import { convertToSAPMessages } from '@mymediset/sap-ai-provider'; +// Translate user input from German to English +const inputTranslation = buildTranslationConfig("input", { + sourceLanguage: "de", + targetLanguage: "en", +}); + +// Translate model output from English to German +const outputTranslation = buildTranslationConfig("output", { + targetLanguage: "de", +}); -const prompt = [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'Hello!' } -]; +const provider = createSAPAIProvider({ + defaultSettings: { + translation: { + input: inputTranslation, + output: outputTranslation, + }, + }, +}); -const sapMessages = convertToSAPMessages(prompt); +// Now the model handles German input/output automatically +const model = provider("gpt-4o"); ``` +**Run it:** `npx tsx examples/example-translation.ts` + --- ## Response Formats @@ -517,8 +1955,11 @@ const sapMessages = convertToSAPMessages(prompt); ### Text Response **Type:** + ```typescript -{ type: 'text' } +{ + type: "text"; +} ``` Default response format for text-only outputs. @@ -528,8 +1969,11 @@ Default response format for text-only outputs. ### JSON Object Response **Type:** + ```typescript -{ type: 'json_object' } +{ + type: "json_object"; +} ``` Instructs the model to return valid JSON. @@ -539,6 +1983,7 @@ Instructs the model to return valid JSON. ### JSON Schema Response **Type:** + ```typescript { type: 'json_schema'; @@ -554,24 +1999,25 @@ Instructs the model to return valid JSON. Instructs the model to follow a specific JSON schema. **Example:** + ```typescript const settings: SAPAISettings = { responseFormat: { - type: 'json_schema', + type: "json_schema", json_schema: { - name: 'user_profile', - description: 'User profile information', + name: "user_profile", + description: "User profile information", schema: { - type: 'object', + type: "object", properties: { - name: { type: 'string' }, - age: { type: 'number' } + name: { type: "string" }, + age: { type: "number" }, }, - required: ['name'] + required: ["name"], }, - strict: true - } - } + strict: true, + }, + }, }; ``` @@ -579,47 +2025,38 @@ const settings: SAPAISettings = { ## Environment Variables -| Variable | Description | Required | -|----------|-------------|----------| -| `SAP_AI_SERVICE_KEY` | Full JSON service key from SAP BTP | No (if token provided) | -| `SAP_AI_TOKEN` | Direct OAuth2 access token | No (if service key provided) | -| `SAP_AI_BASE_URL` | Custom API base URL | No | -| `SAP_AI_DEPLOYMENT_ID` | Custom deployment ID | No | -| `SAP_AI_RESOURCE_GROUP` | Custom resource group | No | +| Variable | Description | Required | +| -------------------- | ------------------------------------------- | ----------- | +| `AICORE_SERVICE_KEY` | SAP AI Core service key JSON (local) | Yes (local) | +| `VCAP_SERVICES` | Service bindings (auto-detected on SAP BTP) | Yes (BTP) | --- -## Error Codes - -Common HTTP status codes returned by SAP AI Core: +## Version Information -| Code | Description | Retryable | Common Causes | -|------|-------------|-----------|---------------| -| 400 | Bad Request | No | Invalid parameters, malformed request | -| 401 | Unauthorized | No | Invalid/expired token | -| 403 | Forbidden | No | Insufficient permissions | -| 404 | Not Found | No | Invalid model ID or deployment | -| 429 | Too Many Requests | Yes | Rate limit exceeded | -| 500 | Internal Server Error | Yes | Service issue | -| 502 | Bad Gateway | Yes | Network/proxy issue | -| 503 | Service Unavailable | Yes | Service temporarily down | -| 504 | Gateway Timeout | Yes | Request timeout | +For the current package version, see [package.json](./package.json). ---- +### Dependencies -## Version Information +- **Vercel AI SDK:** v6.0+ (`ai` package) +- **SAP AI SDK:** ^2.5.0 (`@sap-ai-sdk/orchestration`) +- **Node.js:** >= 18 -- **API Version:** v2 (with v1 legacy support) -- **SDK Version:** Vercel AI SDK v5+ -- **Node Version:** >= 18 +> **Note:** For exact dependency versions, always refer to `package.json` in the +> repository root. --- ## Related Documentation -- [README.md](./README.md) - Getting started guide -- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture -- [TESTING.md](./TESTING.md) - Testing guide -- [DEPLOYMENT.md](./DEPLOYMENT.md) - Production deployment -- [CONTRIBUTING.md](./CONTRIBUTING.md) - Contribution guidelines - +- [README](./README.md) - Getting started, quick start, and feature overview +- [Environment Setup](./ENVIRONMENT_SETUP.md) - Authentication setup and + environment configuration +- [Migration Guide](./MIGRATION_GUIDE.md) - Migration from v1.x with + troubleshooting +- [Architecture](./ARCHITECTURE.md) - Internal architecture, component + design, and request flows +- [cURL API Testing Guide](./CURL_API_TESTING_GUIDE.md) - Low-level API + testing and debugging +- [Contributing Guide](./CONTRIBUTING.md) - Development setup and contribution + guidelines diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3bf1783..07b7e1b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,43 +1,107 @@ # SAP AI Core Provider Architecture -This document provides a detailed overview of the SAP AI Core Provider's architecture, internal components, and integration patterns. +This document provides a detailed overview of the SAP AI Core Provider's +architecture, internal components, and integration patterns. + +**For general usage**, see [README](./README.md). **For API documentation**, +see [API Reference](./API_REFERENCE.md). + +## TL;DR (Executive Summary) + +**3-layer architecture** bridging your application to SAP AI services: + +- **Application** → **Provider** → **SAP AI Core** → AI Models +- Implements Vercel AI SDK's `ProviderV3` interface +- Uses SAP AI SDK (`@sap-ai-sdk/orchestration`) for OAuth2 auth +- Transforms messages bidirectionally (AI SDK ↔ SAP format) +- Supports streaming, tool calling, multi-modal, data masking, and embeddings + +**Key Components:** Provider → OAuth Manager → Message Transformer → Error +Handler → SAP AI Core API ## Table of Contents +- [TL;DR (Executive Summary)](#tldr-executive-summary) - [Overview](#overview) + - [High-Level Architecture](#high-level-architecture) + - [Component Interaction Flow](#component-interaction-flow) + - [Key Design Principles](#key-design-principles) - [Component Architecture](#component-architecture) + - [Component Interaction Map](#component-interaction-map) + - [Detailed Component Flow](#detailed-component-flow) + - [Component Responsibilities](#component-responsibilities) + - [`SAPAIProvider`](#sapaiprovider) + - [`SAPAILanguageModel`](#sapailanguagemodel) + - [`Authentication System`](#authentication-system) + - [`Message Conversion`](#message-conversion) - [Request/Response Flow](#requestresponse-flow) -- [Authentication System](#authentication-system) + - [Standard Text Generation (Complete Flow)](#standard-text-generation-complete-flow) + - [Streaming Text Generation (SSE Flow)](#streaming-text-generation-sse-flow) + - [Orchestration v2 Endpoint](#orchestration-v2-endpoint) + - [Request Structure (v2)](#request-structure-v2) + - [Response Structure (v2)](#response-structure-v2) + - [Templating and Tools (v2)](#templating-and-tools-v2) + - [Data Masking Module (v2)](#data-masking-module-v2) + - [Request Cancellation](#request-cancellation) + - [Tool Calling Flow](#tool-calling-flow) + - [Data Masking Flow (SAP DPI Integration)](#data-masking-flow-sap-dpi-integration) +- [Authentication System](#authentication-system-1) + - [OAuth2 Authentication Flow](#oauth2-authentication-flow) + - [OAuth2 Flow](#oauth2-flow) - [Error Handling](#error-handling) + - [Error Conversion Architecture](#error-conversion-architecture) + - [Error Classification](#error-classification) + - [Retry Mechanism](#retry-mechanism) + - [User-Facing Error Handling (v3.0.0+)](#user-facing-error-handling-v300) - [Type System](#type-system) + - [Model Configuration Types](#model-configuration-types) + - [Request/Response Schemas](#requestresponse-schemas) - [Integration Patterns](#integration-patterns) + - [Provider Pattern](#provider-pattern) + - [Adapter Pattern](#adapter-pattern) + - [Strategy Pattern](#strategy-pattern) - [Performance Considerations](#performance-considerations) + - [Request Optimization](#request-optimization) + - [Memory Management](#memory-management) + - [Monitoring and Observability](#monitoring-and-observability) + - [Scalability Patterns](#scalability-patterns) +- [See Also](#see-also) ## Overview -The SAP AI Core Provider is designed as a bridge between the Vercel AI SDK and SAP AI Core services. It implements the Vercel AI SDK's `ProviderV2` interface while handling the complexities of SAP AI Core's API, authentication, and data formats. +The SAP AI Core Provider is designed as a bridge between the Vercel AI SDK and +SAP AI Core services. It implements the Vercel AI SDK's `ProviderV3` interface +while handling the complexities of SAP AI Core's API, authentication, and data +formats. ### High-Level Architecture +The diagram below illustrates the complete architecture of the SAP AI Provider, +showing how it integrates your application with SAP AI Core through the Vercel +AI SDK. The provider layer handles OAuth2 authentication, message transformation +between AI SDK and SAP formats, and error handling. SAP AI Core routes requests +to various AI models (OpenAI GPT, Anthropic Claude, Google Gemini, Amazon Nova, +and open-source models). + ```mermaid graph TB subgraph "Application Layer" App[Your Application] SDK[Vercel AI SDK] end - + subgraph "Provider Layer" Provider[SAP AI Provider] Auth[OAuth2 Manager] Transform[Message Transformer] Error[Error Handler] end - + subgraph "SAP BTP" OAuth[OAuth2 Server] SAPAI[SAP AI Core Orchestration API] end - + subgraph "AI Models" GPT[OpenAI GPT-4/4o] Claude[Anthropic Claude] @@ -45,7 +109,7 @@ graph TB Nova[Amazon Nova] OSS[Open Source Models] end - + App -->|generateText/streamText| SDK SDK -->|doGenerate/doStream| Provider Provider -->|Get Token| Auth @@ -69,7 +133,7 @@ graph TB Error -->|Transform| Provider Provider -->|AI SDK Format| SDK SDK -->|Result| App - + style Provider fill:#e1f5ff style SDK fill:#fff4e1 style SAPAI fill:#ffe1f5 @@ -78,6 +142,13 @@ graph TB ### Component Interaction Flow +This sequence diagram shows the complete request lifecycle from your application +through the AI SDK and provider to SAP AI Core. The flow is divided into four +phases: Authentication (OAuth2 token retrieval), Message Transformation +(converting AI SDK format to SAP format), API Request & Response (communication +with SAP AI Core and the AI model), and Response Processing (parsing and +converting back to AI SDK format). + ```mermaid sequenceDiagram participant App as Application @@ -90,7 +161,7 @@ sequenceDiagram App->>SDK: generateText(config) SDK->>Prov: doGenerate(options) - + rect rgb(240, 248, 255) Note over Prov,Auth: Authentication Phase Prov->>Auth: getToken() @@ -98,13 +169,13 @@ sequenceDiagram SAP-->>Auth: access_token Auth-->>Prov: Bearer token end - + rect rgb(255, 248, 240) Note over Prov,Trans: Message Transformation Prov->>Trans: convertToSAPMessages(prompt) Trans-->>Prov: SAP format messages end - + rect rgb(248, 255, 240) Note over Prov,Model: API Request & Response Prov->>SAP: POST /v2/completion @@ -114,14 +185,14 @@ sequenceDiagram SAP-->>Prov: Orchestration response Note left of SAP: Response:
- intermediate_results
- final_result
- usage stats end - + rect rgb(255, 240, 248) Note over Prov,SDK: Response Processing Prov->>Prov: Parse & validate Prov->>Prov: Extract content & tool calls - Prov-->>SDK: LanguageModelV2Result + Prov-->>SDK: LanguageModelV3Result end - + SDK-->>App: GenerateTextResult ``` @@ -135,78 +206,37 @@ sequenceDiagram ## Component Architecture -### Core Components Structure - -```mermaid -graph LR - subgraph "Public API" - Index[index.ts
Exports] - end - - subgraph "Provider Layer" - Provider[sap-ai-provider.ts
Factory & Auth] - Model[sap-ai-chat-language-model.ts
LanguageModelV2 Implementation] - end - - subgraph "Utilities" - Settings[sap-ai-chat-settings.ts
Type Definitions] - Error[sap-ai-error.ts
Error Handling] - Convert[convert-to-sap-messages.ts
Message Transformation] - end - - subgraph "Type System" - ReqTypes[types/completion-request.ts
Request Schemas] - ResTypes[types/completion-response.ts
Response Schemas] - end - - Index --> Provider - Index --> Model - Index --> Settings - Index --> Error - - Provider --> Model - Provider --> Settings - - Model --> Convert - Model --> Error - Model --> ReqTypes - Model --> ResTypes - Model --> Settings - - Convert --> Settings - - style Index fill:#e1f5ff - style Provider fill:#fff4e1 - style Model fill:#ffe1f5 - style Utilities fill:#f0f0f0 - style Type System fill:#f5f5f5 -``` - ### Component Interaction Map +This diagram details the responsibilities of each major component in the +provider architecture, including the SAPAIProvider (OAuth2 management, +configuration), SAPAILanguageModel (request/response handling, tool calls), +Authentication System (token management), Message Transformer (format +conversion), API Client (HTTP communication), and Error Handling system. + ```mermaid graph TB subgraph "Component Responsibilities" Provider[SAPAIProvider
━━━━━━━━━━━━━
• OAuth2 Management
• Provider Factory
• Configuration
• Deployment Setup] - - Model[SAPAIChatLanguageModel
━━━━━━━━━━━━━━━━━
• doGenerate/doStream
• Request Building
• Response Parsing
• Tool Call Handling
• Multi-modal Support] - + + Model[SAPAILanguageModel
━━━━━━━━━━━━━━━━━
• doGenerate/doStream
• Request Building
• Response Parsing
• Tool Call Handling
• Multi-modal Support] + Auth[Authentication System
━━━━━━━━━━━━━━
• Token Acquisition
• Service Key Parsing
• Credential Validation
• Token Caching] - + Transform[Message Transformer
━━━━━━━━━━━━━━━
• Prompt Conversion
• Tool Call Mapping
• Multi-modal Handling
• Format Validation] - + ErrorSys[Error System
━━━━━━━━━━━━
• Error Classification
• Retry Logic
• Error Transformation
• Status Code Mapping] - + Types[Type System
━━━━━━━━━━━
• Zod Schemas
• Request/Response Types
• Validation
• Type Safety] end - + Provider -->|Creates| Model Provider -->|Uses| Auth Model -->|Uses| Transform Model -->|Uses| ErrorSys Model -->|Uses| Types Transform -->|Uses| Types - + style Provider fill:#e1f5ff style Model fill:#ffe1f5 style Auth fill:#fff4e1 @@ -217,38 +247,48 @@ graph TB ### Detailed Component Flow -``` +```text src/ ├── index.ts # Public API exports ├── sap-ai-provider.ts # Main provider factory -├── sap-ai-chat-language-model.ts # Language model implementation -├── sap-ai-chat-settings.ts # Model configuration types +├── sap-ai-language-model.ts # Language model implementation +├── sap-ai-embedding-model.ts # Embedding model implementation +├── sap-ai-settings.ts # Settings and type definitions ├── sap-ai-error.ts # Error handling system -├── convert-to-sap-messages.ts # Message format conversion -└── types/ - ├── completion-request.ts # Request format schemas - └── completion-response.ts # Response format schemas +└── convert-to-sap-messages.ts # Message format conversion ``` ### Component Responsibilities #### `SAPAIProvider` -- **Purpose**: Factory for creating language model instances + +- **Purpose**: Factory for creating language and embedding model instances - **Responsibilities**: - Authentication management - Configuration validation - - Model instance creation + - Model instance creation (language and embedding) - Base URL and deployment management -#### `SAPAIChatLanguageModel` -- **Purpose**: Implementation of Vercel AI SDK's `LanguageModelV2` +#### `SAPAILanguageModel` + +- **Purpose**: Implementation of Vercel AI SDK's `LanguageModelV3` - **Responsibilities**: - Request/response transformation - Streaming support - Tool calling implementation - Multi-modal input handling +#### `SAPAIEmbeddingModel` + +- **Purpose**: Implementation of Vercel AI SDK's `EmbeddingModelV3` +- **Responsibilities**: + - Embedding generation via `doEmbed()` + - Batch size validation (`maxEmbeddingsPerCall`) + - AbortSignal handling for request cancellation + - Uses `OrchestrationEmbeddingClient` from SAP AI SDK + #### `Authentication System` + - **Purpose**: OAuth2 token management for SAP AI Core - **Responsibilities**: - Service key parsing @@ -256,6 +296,7 @@ src/ - Credential validation #### `Message Conversion` + - **Purpose**: Format translation between AI SDK and SAP AI Core - **Responsibilities**: - Prompt format conversion @@ -266,6 +307,11 @@ src/ ### Standard Text Generation (Complete Flow) +This detailed sequence diagram shows the complete flow for a standard text +generation request, including all steps from application call through +authentication, message transformation, SAP AI Core API communication, and +response processing back to the application. + ```mermaid sequenceDiagram participant App as Application @@ -290,7 +336,7 @@ sequenceDiagram rect rgb(240, 255, 240) Note over Provider,Auth: 3. Authentication Provider->>Auth: getAuthToken() - + alt Token Cached Auth-->>Provider: Cached token else Token Expired/Missing @@ -327,13 +373,13 @@ sequenceDiagram rect rgb(240, 255, 255) Note over Provider,SDK: 7. Response Processing Provider->>Provider: Parse response
• Extract content
• Extract tool calls
• Calculate usage - + alt v2 Response Provider->>Provider: Use final_result else v1 Fallback Provider->>Provider: Use module_results.llm end - + Provider-->>SDK: {
content: [...],
usage: {...},
finishReason: "stop",
warnings: []
} end @@ -346,6 +392,11 @@ sequenceDiagram ### Streaming Text Generation (SSE Flow) +This diagram illustrates the streaming text generation flow using Server-Sent +Events (SSE). Unlike standard generation, streaming returns partial responses +incrementally as the AI model generates content, enabling real-time display of +results to users. + ```mermaid sequenceDiagram participant App as Application @@ -374,13 +425,13 @@ sequenceDiagram SAP-->>Provider: data: {
intermediate_results: {
llm: {
choices: [{
delta: {content: "token"}
}]
}
}
} Provider->>Provider: Parse SSE chunk Provider->>Provider: Transform to StreamPart - + alt First Chunk Provider-->>SDK: {type: "stream-start"} Provider-->>SDK: {type: "response-metadata"} Provider-->>SDK: {type: "text-start"} end - + Provider-->>SDK: {
type: "text-delta",
id: "0",
delta: "token"
} SDK-->>App: Stream chunk App->>App: Display token @@ -399,38 +450,35 @@ sequenceDiagram ### Orchestration v2 Endpoint -SAP AI Core Orchestration v2 introduces a more structured API with improved capabilities: +SAP AI Core Orchestration v2 introduces a more structured API with improved +capabilities: **Default Path:** -``` + +```text ${baseURL}/inference/deployments/{deploymentId}/v2/completion ``` **Top-level v2 endpoint:** -``` + +```http POST /v2/completion ``` + ([documentation](https://api.sap.com/api/ORCHESTRATION_API_v2/resource/Orchestrated_Completion)) **Configuration:** -```typescript -// Default (recommended): uses deployment-specific v2 endpoint -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY -}); -// Top-level v2 endpoint -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - baseURL: 'https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com', - completionPath: '/v2/completion' +```typescript +// Default configuration +const provider = createSAPAIProvider({ + resourceGroup: "default", }); -// v1 (legacy - deprecated, decommission on 31 Oct 2026) -const providerV1 = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - baseURL: 'https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com', - completionPath: '/completion' +// With specific deployment +const provider = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", + resourceGroup: "production", }); ``` @@ -504,11 +552,13 @@ The v2 API uses a modular configuration structure: ### Templating and Tools (v2) **Prompt Templating:** + - Messages are passed under `config.modules.prompt_templating.prompt.template` - Supports system, user, assistant, tool, and developer roles - Multi-modal content (text + images) supported **Response Format:** + ```typescript // Text (default when no tools) response_format: { type: "text" } @@ -533,23 +583,26 @@ response_format: { ``` **Tool Definitions:** + ```typescript -tools: [{ - type: "function", - function: { - name: "calculator", - description: "Perform arithmetic operations", - parameters: { - type: "object", - properties: { - operation: { type: "string", enum: ["add", "subtract"] }, - a: { type: "number" }, - b: { type: "number" } +tools: [ + { + type: "function", + function: { + name: "calculator", + description: "Perform arithmetic operations", + parameters: { + type: "object", + properties: { + operation: { type: "string", enum: ["add", "subtract"] }, + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["operation", "a", "b"], }, - required: ["operation", "a", "b"] - } - } -}] + }, + }, +]; ``` ### Data Masking Module (v2) @@ -585,20 +638,37 @@ modules: { ``` **Masking Flow:** + 1. Input passes through masking module 2. Sensitive data is anonymized/pseudonymized 3. Masked data sent to LLM 4. Response passes through output_unmasking (if configured) 5. Original values restored in final output - SAP-->>Provider: Server-Sent Events - loop For each SSE chunk - Provider->>Provider: parseStreamChunk() - Provider-->>SDK: LanguageModelV2StreamPart - SDK-->>App: TextStreamPart - end + +### Request Cancellation + +The provider supports HTTP-level request cancellation via `AbortSignal`. + +**Non-streaming:** + +```typescript +const response = await client.chatCompletion(requestBody, options.abortSignal ? { signal: options.abortSignal } : undefined); ``` -### Tool Calling Flow (Function Calling) +**Streaming:** + +```typescript +const stream = await client.stream(requestBody, options.abortSignal, streamOptions, requestConfig); +``` + +The signal passes through `requestConfig` to the SAP AI SDK, which forwards it to the underlying Axios HTTP client. When aborted, the HTTP connection is closed and server-side processing stops. + +### Tool Calling Flow + +This diagram shows how tool calling (function calling) works. When the AI model +needs to call a tool, it returns structured tool call requests. Your application +executes the tools and provides results back, which the model uses to generate +the final response. ```mermaid sequenceDiagram @@ -638,7 +708,7 @@ sequenceDiagram rect rgb(240, 240, 255) Note over SDK,Tool: 5. Tool Execution SDK->>App: Execute tools - + par Parallel Execution (if enabled) App->>Tool: calculate({a: 5, b: 3}) Tool-->>App: 8 @@ -646,7 +716,7 @@ sequenceDiagram App->>Tool: getWeather({city: "Tokyo"}) Tool-->>App: "sunny, 72°F" end - + App->>SDK: Tool results end @@ -669,6 +739,10 @@ sequenceDiagram ### Data Masking Flow (SAP DPI Integration) +This diagram illustrates how SAP Data Privacy Integration (DPI) works. When +enabled, sensitive data in prompts is automatically masked before being sent to +AI models, and the masked entities are tracked and unmasked in responses. + ```mermaid sequenceDiagram participant App as Application @@ -716,6 +790,11 @@ sequenceDiagram ### OAuth2 Authentication Flow +This diagram shows how OAuth2 authentication works with token caching. The +provider checks for a valid cached token first; if expired or missing, it +requests a new token using client credentials, caches it, and uses it for API +requests. + ```mermaid sequenceDiagram participant App as Application @@ -725,9 +804,9 @@ sequenceDiagram participant SAPAI as SAP AI Core API rect rgb(240, 248, 255) - Note over App,Provider: 1. Provider Initialization - App->>Provider: createSAPAIProvider({
serviceKey: {
clientid: "...",
clientsecret: "...",
url: "https://...auth.../oauth/token"
}
}) - Provider->>Provider: Parse service key
Extract credentials + Note over App,Provider: 1. Provider Initialization (v2.0+) + App->>Provider: createSAPAIProvider()
(synchronous, no await needed) + Provider->>Provider: Initialize with SAP AI SDK
Authentication handled automatically end rect rgb(255, 248, 240) @@ -740,9 +819,9 @@ sequenceDiagram Note over Provider,OAuth: 3. Token Acquisition Provider->>Provider: Encode credentials to Base64
clientid:clientsecret Provider->>OAuth: POST /oauth/token
Headers: {
Authorization: Basic {base64_credentials},
Content-Type: application/x-www-form-urlencoded
}
Body: grant_type=client_credentials - + OAuth->>OAuth: Validate credentials
Check permissions
Generate token - + OAuth-->>Provider: {
access_token: "eyJhbGc...",
token_type: "bearer",
expires_in: 43199,
scope: "...",
jti: "..."
} end @@ -780,146 +859,115 @@ sequenceDiagram ### OAuth2 Flow -The provider implements the OAuth2 client credentials flow for SAP AI Core authentication: +Authentication is handled automatically by `@sap-ai-sdk/orchestration`: -```typescript -// Service key structure from SAP BTP -interface SAPAIServiceKey { - serviceurls: { AI_API_URL: string }; - clientid: string; - clientsecret: string; - url: string; // OAuth2 server URL - // ... other fields -} +- **Local**: `AICORE_SERVICE_KEY` environment variable +- **SAP BTP**: `VCAP_SERVICES` service binding -// Token acquisition process -async function getOAuthToken(serviceKey: SAPAIServiceKey): Promise { - // 1. Create Basic Auth header from client credentials - const credentials = Buffer.from( - `${serviceKey.clientid}:${serviceKey.clientsecret}` - ).toString('base64'); - - // 2. Request token from OAuth2 server - const response = await fetch(`${serviceKey.url}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${credentials}` - }, - body: 'grant_type=client_credentials' - }); +The SDK manages credentials, token acquisition, caching, and refresh internally. + +## Error Handling + +The provider implements robust error handling by converting SAP AI SDK errors to +standard Vercel AI SDK error types for consistent error handling across +providers. + +### Error Conversion Architecture - // 3. Extract access token from response - const { access_token } = await response.json(); - return access_token; +```typescript +// Internal error handling in doGenerate/doStream +try { + const response = await client.chatCompletion({ messages }); + // Process response... +} catch (error) { + // Convert to AI SDK standard errors + throw convertToAISDKError(error, { + operation: "doGenerate", + url: "sap-ai:orchestration", + requestBody: requestSummary, + }); } ``` -### Token Management +### Error Classification -- **Automatic Refresh**: Tokens are refreshed automatically when expired -- **Error Handling**: Authentication errors are caught and retried -- **Security**: Credentials are never logged or exposed in error messages +The `convertToAISDKError()` function handles error conversion with a clear +priority: -## Error Handling +1. **Already AI SDK error?** → Return as-is (no conversion needed) +2. **SAP Orchestration error?** → Convert to `APICallError` with details + extracted from response +3. **Network/auth errors?** → Classify as `LoadAPIKeyError` or `APICallError` + with appropriate status code +4. **Unknown error?** → Generic `APICallError` with status 500 -### Error Hierarchy +All errors include helpful context (operation, URL, request body summary) for +debugging. -```typescript -Error -└── SAPAIError - ├── AuthenticationError (401, 403) - ├── RateLimitError (429) - ├── ValidationError (400) - ├── NotFoundError (404) - └── ServerError (5xx) -``` +### Retry Mechanism -### Error Response Handling +The provider marks errors as retryable based on HTTP status codes (aligned with +Vercel AI SDK defaults): -```typescript -export const sapAIFailedResponseHandler = createJsonErrorResponseHandler({ - errorSchema: sapAIErrorSchema, - errorToMessage: (data) => { - return data?.error?.message || - data?.message || - 'An error occurred during the SAP AI Core request.'; - }, - isRetryable: (response) => { - const status = response.status; - return [429, 500, 502, 503, 504].includes(status); - } -}); -``` +- **408 (Request Timeout)**: `isRetryable: true` → Retry after timeout +- **409 (Conflict)**: `isRetryable: true` → Retry on transient conflicts +- **429 (Rate Limit)**: `isRetryable: true` → Exponential backoff +- **5xx (Server Errors)**: `isRetryable: true` → Exponential backoff +- **400 (Bad Request)**: `isRetryable: false` → Client must fix request +- **401/403 (Auth Errors)**: `isRetryable: false` → Fix credentials +- **404 (Not Found)**: `isRetryable: false` → Fix model/deployment + +The Vercel AI SDK handles retry logic automatically based on the `isRetryable` +flag. + +### User-Facing Error Handling (v3.0.0+) -### Retry Logic +This provider converts all SAP Orchestration errors to standard Vercel AI SDK +error types: -The provider implements exponential backoff for retryable errors: +- **401/403 (Authentication)** → `LoadAPIKeyError` +- **404 (Model/Deployment not found)** → `NoSuchModelError` +- **Other HTTP errors** → `APICallError` with SAP metadata in `responseBody` -1. **Immediate retry**: For network timeouts -2. **Exponential backoff**: For rate limits (429) and server errors (5xx) -3. **Circuit breaker**: After consecutive failures -4. **Jitter**: Random delay to prevent thundering herd +**Breaking change in v3.0.0:** The custom `SAPAIError` class was removed to +ensure full compatibility with the AI SDK ecosystem and enable automatic retry +mechanisms. + +**For implementation details and code examples:** + +- [API Reference - Error Handling Examples](./API_REFERENCE.md#error-handling-examples) - + Complete examples with all error types +- [Troubleshooting Guide](./TROUBLESHOOTING.md#parsing-sap-error-metadata-v300) - + Quick reference and common issues + +**For v2→v3 migration**, see +[Migration Guide - v2 to v3](./MIGRATION_GUIDE.md#version-2x-to-3x-breaking-changes). ## Type System ### Model Configuration Types -```typescript -// Model identifiers with string union for type safety -type SAPAIModelId = - | 'gpt-4o' - | 'claude-3.5-sonnet' - | 'gemini-1.5-pro' - // ... other models - | (string & {}); // Allow custom models - -// Comprehensive settings interface -interface SAPAISettings { - modelVersion?: string; - modelParams?: { - maxTokens?: number; - temperature?: number; - topP?: number; - frequencyPenalty?: number; - presencePenalty?: number; - n?: number; - }; - safePrompt?: boolean; - structuredOutputs?: boolean; -} -``` +Key types for model configuration: + +- **`SAPAIModelId`**: String union of supported models (e.g., "gpt-4o", + "claude-3.5-sonnet", "gemini-1.5-pro") with flexibility for custom models +- **`SAPAISettings`**: Interface with `modelVersion`, `modelParams` (maxTokens, + temperature, topP, etc.), `safePrompt`, and `structuredOutputs` options + +See `src/sap-ai-settings.ts` for complete type definitions. ### Request/Response Schemas -All API interactions are validated using Zod schemas: +All API interactions use types from `@sap-ai-sdk/orchestration` and are +validated for type safety. Key types include: -```typescript -// Request validation -export const sapAIRequestSchema = z.object({ - orchestration_config: z.object({ - module_configurations: z.object({ - llm_module_config: sapAILLMConfigSchema, - }), - }), - input_params: z.object({ - messages: z.array(sapAIMessageSchema), - }), -}); +- `ChatCompletionRequest`: Orchestration config and input parameters +- `OrchestrationResponse`: API responses with module results +- `ChatMessage`: Message format (role, content, tool calls) +- `ChatCompletionTool`: Function definitions and parameters -// Response validation -export const sapAIResponseSchema = z.object({ - request_id: z.string(), - module_results: z.object({ - llm: sapAILLMResultSchema, - templating: sapAITemplatingResultSchema, - }), - orchestration_results: z.object({ - choices: z.array(sapAIChoiceSchema), - usage: sapAIUsageSchema, - }).optional(), -}); -``` +See `src/sap-ai-settings.ts` for the main settings interface and re-exported SAP +AI SDK types. ## Integration Patterns @@ -928,61 +976,27 @@ export const sapAIResponseSchema = z.object({ The provider implements the factory pattern for model creation: ```typescript -interface SAPAIProvider extends ProviderV2 { +interface SAPAIProvider extends ProviderV3 { // Function call syntax - (modelId: SAPAIModelId, settings?: SAPAISettings): SAPAIChatLanguageModel; - + (modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel; + // Method call syntax - chat(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAIChatLanguageModel; + chat(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel; } ``` ### Adapter Pattern -The message conversion system adapts between different formats: - -```typescript -// Vercel AI SDK format -type LanguageModelV2Prompt = Array<{ - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string | Array; -}>; - -// SAP AI Core format -type SAPMessage = { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string | Array<{ - type: 'text' | 'image_url'; - text?: string; - image_url?: { url: string }; - }>; -}; - -// Conversion function -export function convertToSAPMessages( - prompt: LanguageModelV2Prompt -): SAPMessage[] { - // Implementation handles format transformation -} -``` +The message conversion system adapts between Vercel AI SDK format and SAP AI +Core format. The `convertToSAPMessages()` function transforms prompt arrays, +handling text content, images, tool calls, and tool results across different +message formats. ### Strategy Pattern -Different models may require different handling strategies: - -```typescript -class SAPAIChatLanguageModel { - private getModelStrategy(modelId: string) { - if (modelId.startsWith('anthropic--')) { - return new AnthropicStrategy(); - } else if (modelId.startsWith('gemini-')) { - return new GeminiStrategy(); - } else { - return new OpenAIStrategy(); - } - } -} -``` +Different AI models may require different handling strategies based on their +specific requirements and formats. The implementation can adapt behavior based +on model identifiers (e.g., `anthropic--*`, `gemini-*`, etc.). ## Performance Considerations @@ -1001,57 +1015,34 @@ class SAPAIChatLanguageModel { ### Monitoring and Observability -```typescript -// Request tracking -const requestMetrics = { - totalRequests: 0, - successfulRequests: 0, - failedRequests: 0, - averageResponseTime: 0, - tokensUsed: 0 -}; - -// Performance monitoring -function trackRequest(startTime: number, success: boolean, tokens?: number) { - const duration = Date.now() - startTime; - requestMetrics.totalRequests++; - - if (success) { - requestMetrics.successfulRequests++; - if (tokens) requestMetrics.tokensUsed += tokens; - } else { - requestMetrics.failedRequests++; - } - - requestMetrics.averageResponseTime = - (requestMetrics.averageResponseTime + duration) / 2; -} -``` +Consider tracking: + +- Request counts (total, successful, failed) +- Response times and token usage +- Error rates by status code +- Authentication token refresh frequency ### Scalability Patterns 1. **Horizontal Scaling**: Support for multiple instances 2. **Load Balancing**: Distribute requests across deployments 3. **Circuit Breaker**: Prevent cascade failures -4. **Rate Limiting**: Client-side rate limiting to prevent 429s +4. **Rate Limiting**: Client-side rate limiting to prevent 429s (e.g., token + bucket or sliding window algorithm) -```typescript -class RateLimiter { - private requests: number[] = []; - - async acquire(): Promise { - const now = Date.now(); - this.requests = this.requests.filter(time => now - time < 60000); // 1 minute window - - if (this.requests.length >= this.maxRequestsPerMinute) { - const oldestRequest = Math.min(...this.requests); - const waitTime = 60000 - (now - oldestRequest); - await new Promise(resolve => setTimeout(resolve, waitTime)); - } - - this.requests.push(now); - } -} -``` +This architecture ensures the SAP AI Core Provider is robust, scalable, and +maintainable while providing a seamless integration experience with the Vercel +AI SDK. + +--- + +## See Also + +**For getting started and basic usage**, see the [README](./README.md). + +**Related Technical Documentation:** -This architecture ensures the SAP AI Core Provider is robust, scalable, and maintainable while providing a seamless integration experience with the Vercel AI SDK. \ No newline at end of file +- [API Reference](./API_REFERENCE.md) - Complete type definitions and interfaces + referenced in this architecture document +- [cURL API Testing Guide](./CURL_API_TESTING_GUIDE.md) - Low-level API + debugging to understand request/response flows described above diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 16ff97b..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,104 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ---- - -## [Unreleased] - -### Added - -**Documentation:** -- Comprehensive JSDoc comments throughout codebase for better IDE support -- API_REFERENCE.md with complete API documentation -- MIGRATION_GUIDE.md for version upgrade guidance -- Enhanced ARCHITECTURE.md with v2 API details -- Table of Contents in README.md for improved navigation -- Quick Start section in README.md -- Troubleshooting guide with common issues and solutions -- Performance & Best Practices section -- Security best practices documentation -- Debug mode instructions - -**Features:** -- Orchestration v2 API support - - Request body built under `config.modules.prompt_templating` - - Response schemas aligned to v2 (`intermediate_results`, `final_result`) - - Tool calls surfaced via `tool_calls` in choices and stream deltas - - `messages_history` support in request schema - - `output_unmasking` in intermediate results -- Data masking with SAP Data Privacy Integration (DPI) - - Anonymization and pseudonymization support - - Standard and custom entity detection - - Allowlist configuration -- Response format control - - `SAPAISettings.responseFormat` for text/json/json_schema - - Default to `{ type: "text" }` when no tools are used -- `createSAPAIProviderSync` for synchronous initialization with token -- `defaultSettings` option for provider-wide configuration -- `completionPath` option for custom endpoint paths -- Enhanced error messages with `intermediateResults` -- Improved streaming support with better error handling - -**Examples:** -- New streaming example: `examples/example-streaming-chat.ts` -- Updated examples to reflect v2 API and new features -- Data masking example: `examples/example-data-masking.ts` - -### Changed - -**API:** -- Default endpoint: `${baseURL}/inference/deployments/{deploymentId}/v2/completion` -- Legacy v1 endpoint support maintained for backward compatibility -- Enhanced `SAPAIError` with `intermediateResults` property -- Improved type definitions with better JSDoc - -**Documentation:** -- Enhanced documentation for all public interfaces -- More detailed error handling examples -- Expanded configuration options documentation -- Better model selection guidance -- Real-world code examples - -### Deprecated - -- v1 completion endpoint (`POST /completion`) - Decommission on October 31, 2026 -- Use v2 endpoint (`POST /v2/completion`) instead (default) - -### Fixed - -- Improved error messages for authentication failures -- Better handling of v1/v2 API fallback -- Enhanced stream processing reliability - ---- - -## [1.0.3] - 2024-01-XX - -### Added -- Support for multiple SAP AI Core model types -- OAuth2 authentication with automatic token management -- Streaming support for real-time text generation -- Tool calling capabilities for function integration -- Multi-modal input support (text + images) -- Structured output support with JSON schemas -- Comprehensive error handling with automatic retries -- TypeScript support with full type safety - -### Features -- Integration with Vercel AI SDK v5 -- Support for 40+ AI models including GPT-4, Claude, Gemini -- Automatic service key parsing and authentication -- Configurable deployment and resource group settings -- Custom fetch implementation support -- Built-in error recovery and retry logic - -### Initial Release -- Core SAP AI Core provider implementation -- Basic documentation and examples -- Test suite with comprehensive coverage -- Build configuration with TypeScript support -- Package configuration for npm publishing \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..771019c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @jerome-benoit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49cb664..a9d6dc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing to SAP AI Provider -We love your input! We want to make contributing to SAP AI Provider as easy and transparent as possible, whether it's: +We love your input! We want to make contributing to SAP AI Provider as easy and +transparent as possible, whether it's: - Reporting a bug - Discussing the current state of the code @@ -8,9 +9,44 @@ We love your input! We want to make contributing to SAP AI Provider as easy and - Proposing new features - Becoming a maintainer +## Table of Contents + +- [Development Process](#development-process) +- [Development Setup](#development-setup) + - [Prerequisites](#prerequisites) + - [Initial Setup](#initial-setup) + - [Development Workflow](#development-workflow) + - [Pre-Commit Checklist](#pre-commit-checklist) +- [Pull Request Process](#pull-request-process) + - [Versioning](#versioning) +- [Coding Standards](#coding-standards) + - [TypeScript](#typescript) + - [Code Style](#code-style) + - [Testing](#testing) + - [Error Handling](#error-handling) + - [Documentation](#documentation) +- [Testing Guidelines](#testing-guidelines) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) + - [Test Coverage](#test-coverage) +- [Architecture Guidelines](#architecture-guidelines) + - [Provider Integration](#provider-integration) + - [Performance](#performance) + - [Security](#security) +- [Advanced: Detailed Developer Instructions](#advanced-detailed-developer-instructions) +- [Report Bugs](#report-bugs) +- [Request Features](#request-features) +- [License](#license) +- [Code of Conduct](#code-of-conduct) + - [Our Standards](#our-standards) + - [Unacceptable Behavior](#unacceptable-behavior) +- [Getting Help](#getting-help) +- [Recognition](#recognition) + ## Development Process -We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. +We use GitHub to host code, to track issues and feature requests, as well as +accept pull requests. 1. Fork the repo and create your branch from `main` 2. If you've added code that should be tested, add tests @@ -19,42 +55,364 @@ We use GitHub to host code, to track issues and feature requests, as well as acc 5. Make sure your code lints 6. Issue that pull request! +## Development Setup + +### Prerequisites + +- Node.js 18 or higher +- npm or yarn +- Git +- SAP AI Core service key (for testing with real API) + +### Initial Setup + +1. **Fork and clone the repository** + + ```bash + git clone https://github.com/YOUR-USERNAME/sap-ai-provider.git + cd sap-ai-provider + ``` + +2. **Install dependencies** + + ```bash + npm install # or npm ci if package-lock.json exists + ``` + +3. **Set up environment variables** (optional, for testing) + + ```bash + # Create .env file + cp .env.example .env + + # Edit .env and add your AICORE_SERVICE_KEY + ``` + +4. **Verify installation** + + ```bash + npm run build + npm test + ``` + +### Development Workflow + +Our development workflow follows these steps: + +1. **Make your changes** in `/src` directory + - Follow existing code style and patterns + - Add JSDoc comments for public APIs + - Keep components focused and single-purpose + +2. **Run type checking** + + ```bash + npm run type-check + ``` + +3. **Run tests** + + ```bash + npm test # Run all tests + npm run test:node # Node.js environment + npm run test:edge # Edge runtime environment + npm run test:watch # Watch mode for development + ``` + +4. **Check code formatting** + + ```bash + npm run prettier-check # Check formatting + npm run prettier-fix # Auto-fix formatting + ``` + +5. **Lint your code** + + ```bash + npm run lint # Check for issues + npm run lint-fix # Auto-fix issues + ``` + +6. **Build the library** + + ```bash + npm run build + npm run check-build # Verify outputs + ``` + +7. **Test with examples** (requires SAP credentials) + + ```bash + npx tsx examples/example-generate-text.ts + ``` + +### Pre-Commit Checklist + +Before committing, ensure ALL of these pass: + +```bash +npm run type-check && \ +npm run test && \ +npm run test:node && \ +npm run test:edge && \ +npm run prettier-check && \ +npm run lint && \ +npm run build && \ +npm run check-build +``` + +This validation takes approximately 15 seconds and ensures CI will pass. + ## Pull Request Process -1. Update the README.md with details of changes to the interface, if applicable -2. Update the package version in package.json following [Semantic Versioning](https://semver.org/) -3. Update the CHANGELOG.md with a note describing your changes -4. The PR will be merged once you have the sign-off of at least one maintainer +1. **Update documentation** - Update README.md, API_REFERENCE.md, or other docs + if you've changed the API +2. **Follow commit conventions** - Use + [Conventional Commits](https://www.conventionalcommits.org/) format: + - `feat:` for new features + - `fix:` for bug fixes + - `docs:` for documentation changes + - `test:` for test additions/changes + - `refactor:` for code refactoring + - `chore:` for maintenance tasks +3. **Add tests** - All new features and bug fixes must include tests +4. **Request review** - The PR will be merged once you have the sign-off of at + least one maintainer + +### Versioning + +We follow [Semantic Versioning](https://semver.org/): + +- **MAJOR** (x.0.0): Breaking changes +- **MINOR** (0.x.0): New features, backwards compatible +- **PATCH** (0.0.x): Bug fixes, backwards compatible + +Version bumping is handled by maintainers during release process. + +## Coding Standards + +### TypeScript + +- Use strict TypeScript configuration +- Prefer explicit types over `any` +- Add JSDoc comments to all public APIs +- Export types that are part of public API +- Use `zod` schemas for runtime validation + +### Code Style + +- 2 spaces for indentation (no tabs) +- Follow existing code patterns +- Use meaningful variable and function names +- Keep functions small and focused +- Avoid deep nesting (max 3 levels) + +### Testing + +- Write unit tests for all new functionality +- Test both Node.js and Edge runtime environments +- Use descriptive test names +- Cover error cases and edge cases +- Mock external dependencies appropriately + +### Error Handling + +- Use Vercel AI SDK standard errors (`APICallError`, `LoadAPIKeyError`, `NoSuchModelError`) +- Provide clear, actionable error messages +- Include debugging context (request IDs, locations) +- Follow existing error patterns + +### Documentation + +- JSDoc comments for all public functions, classes, and interfaces +- Include `@example` blocks for complex APIs +- Update README.md for user-facing changes +- Update API_REFERENCE.md for API changes +- Keep documentation concise and precise -## Any Contributions You Make Will Be Under the Apache License 2.0 +#### Documentation Guidelines -In short, when you submit code changes, your submissions are understood to be under the same [Apache License 2.0](LICENSE.md) that covers the project. Feel free to contact the maintainers if that's a concern. +When adding new features or changing APIs, follow these guidelines to maintain +documentation quality: -## Report Bugs Using GitHub's [Issue Tracker](https://github.com/BITASIA/sap-ai-provider/issues) + -We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/BITASIA/sap-ai-provider/issues/new); it's that easy! +**1. Single Source of Truth** -## Write Bug Reports with Detail, Background, and Sample Code +Each piece of information should have ONE authoritative location: -**Great Bug Reports** tend to have: +- **API details** → `API_REFERENCE.md` +- **Setup instructions** → `ENVIRONMENT_SETUP.md` +- **Error solutions** → `TROUBLESHOOTING.md` +- **Architecture decisions** → `ARCHITECTURE.md` +- **Breaking changes** → `MIGRATION_GUIDE.md` +- **Quick start** → `README.md` (with links to detailed docs) -- A quick summary and/or background -- Steps to reproduce - - Be specific! - - Give sample code if you can -- What you expected would happen -- What actually happens -- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) +**2. Avoid Duplication** -## Use a Consistent Coding Style +When referencing information from another doc, use links instead of copying: -- 2 spaces for indentation rather than tabs -- You can try running `npm run lint` for style unification +```markdown + + +To set up authentication, create a service key in SAP BTP... + + + +See [Environment Setup Guide](./ENVIRONMENT_SETUP.md#authentication) for +authentication setup. +``` + +**3. Update Checklist for New Features** + +- [ ] Add to `API_REFERENCE.md` with TypeScript signatures and examples +- [ ] Update `README.md` if user-facing (keep concise, link to API_REFERENCE) +- [ ] Create example in `examples/` directory if significant feature +- [ ] Update `MIGRATION_GUIDE.md` if breaking change +- [ ] Run `npm run build` to ensure TypeScript compiles + +**4. Example Code Guidelines** + +- Use relative imports (`../src/index`) for repo examples +- Add comment explaining production import path: + + ```typescript + // NOTE: This example uses relative imports for local development + // In your project, use: import { ... } from "@jerome-benoit/sap-ai-provider" + ``` + +**5. Documentation Verification** + +Before submitting a PR, run: + +```bash +npm run build # Ensures TypeScript compiles +npm test # Runs test suite +``` + + + +## Testing Guidelines + +### Unit Tests + +- Located in `*.test.ts` files alongside source +- Use Vitest as test framework +- Mock SAP AI SDK calls for offline testing +- Test both success and error paths + +### Integration Tests + +- Test actual integration with SAP AI SDK +- Require `AICORE_SERVICE_KEY` to run +- Can be skipped in CI if credentials not available + +### Test Coverage + +- Aim for >80% code coverage +- Focus on critical paths and error handling +- Don't test trivial getters/setters + +## Architecture Guidelines + +### Provider Integration + +- Implement Vercel AI SDK interfaces correctly +- Follow the separation: provider factory → language model +- Maintain compatibility with Node.js and Edge runtimes +- Use existing authentication patterns + +### Performance + +- Streaming responses must be efficient +- Avoid blocking operations in request/response flow +- Consider memory usage for large responses +- Use caching where appropriate + +### Security + +- Never expose service keys or tokens in logs +- Validate all external inputs with zod schemas +- Follow secure credential handling patterns +- Check for injection vulnerabilities + +## Advanced: Detailed Developer Instructions + +For comprehensive developer workflow and best practices, see +[`.github/copilot-instructions.md`](./.github/copilot-instructions.md). This +file contains: + +- Detailed build and test procedures +- CI/CD pipeline information +- Code review guidelines +- Troubleshooting common development issues + +## Report Bugs + +We use GitHub issues to track bugs. Report a bug by +[opening a new issue](https://github.com/jerome-benoit/sap-ai-provider/issues/new?template=bug_report.md). + +**Great Bug Reports** include: + +- Quick summary and background +- Steps to reproduce (be specific!) +- Sample code if possible +- What you expected vs. what actually happened +- Notes on what you tried that didn't work + +## Request Features + +Request features by +[opening a feature request](https://github.com/jerome-benoit/sap-ai-provider/issues/new?template=feature_report.md). + +**Good Feature Requests** include: + +- Clear description of the problem to solve +- Proposed solution with examples +- Alternatives you've considered +- Any additional context or screenshots ## License -By contributing, you agree that your contributions will be licensed under its Apache License 2.0. +By contributing, you agree that your contributions will be licensed under the +Apache License 2.0. + +## Code of Conduct + +### Our Standards + +- Be respectful and inclusive +- Welcome newcomers and help them learn +- Focus on what's best for the community +- Show empathy towards others +- Accept constructive criticism gracefully + +### Unacceptable Behavior + +- Harassment, discrimination, or offensive comments +- Trolling, insulting, or derogatory remarks +- Publishing others' private information +- Other conduct inappropriate in a professional setting + +## Getting Help + +- 📖 Read the documentation: [README](./README.md), + [API Reference](./API_REFERENCE.md) +- 🐛 Report issues or ask questions: + [Issue Tracker](https://github.com/jerome-benoit/sap-ai-provider/issues) +- 👥 Join the community and share your experience + +## Recognition + +Contributors will be recognized in: + +- GitHub contributors page +- Release notes for significant contributions +- Project README (for major features) + +Thank you for contributing to SAP AI Provider! 🎉 -## References +--- -This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md). +This document was adapted from open-source contribution guidelines and is +licensed under CC-BY-4.0. diff --git a/CURL_API_TESTING_GUIDE.md b/CURL_API_TESTING_GUIDE.md index e9fa6db..5ef9396 100644 --- a/CURL_API_TESTING_GUIDE.md +++ b/CURL_API_TESTING_GUIDE.md @@ -1,43 +1,59 @@ # SAP AI Core API - Manual curl Testing Guide -This guide demonstrates how to make direct API calls to SAP AI Core using curl for testing and debugging purposes. +This guide shows how to make direct API calls to SAP AI Core using curl for +testing and debugging. For production code, use the SAP AI SDK with +`AICORE_SERVICE_KEY`. --- +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Step-by-Step Guide](#step-by-step-guide) + - [Step 1: Prepare Credentials](#step-1-prepare-credentials) + - [Step 2: Get OAuth Token](#step-2-get-oauth-token) + - [Step 3: Call SAP AI Core API](#step-3-call-sap-ai-core-api) +- [Request Body Structure (Orchestration v2)](#request-body-structure-orchestration-v2) + - [Basic Structure](#basic-structure) + - [Request Structure](#request-structure) +- [Tool Calling Example](#tool-calling-example) + + - [⚠️ Model-Specific Limitations](#model-specific-limitations) +- [Complete Working Example](#complete-working-example) +- [Response Format](#response-format) + - [Success Response (HTTP 200)](#success-response-http-200) + - [Error Response (HTTP 400)](#error-response-http-400) +- [Common Issues](#common-issues) +- [Debugging Tips](#debugging-tips) +- [Security Best Practices](#security-best-practices) +- [Additional Resources](#additional-resources) +- [TypeScript Examples](#typescript-examples) + ## Overview -This example shows the complete flow of: -1. **OAuth2 authentication** using service key credentials -2. **API call** to SAP AI Core's Orchestration v2 endpoint -3. **Function calling** with multiple tools (model-dependent) +Complete OAuth2 authentication → API call → Tool calling flow. --- ## Prerequisites -- SAP AI Core instance with deployment -- Service key from SAP BTP cockpit -- `curl` command-line tool -- `base64` encoding utility +- SAP AI Core instance + service key (from BTP cockpit) +- `curl` and `base64` utilities --- ## Step-by-Step Guide -### Step 1: Prepare Your Credentials +### Step 1: Prepare Credentials -From your SAP BTP cockpit, obtain your service key which contains: -- `clientid` - OAuth2 client ID -- `clientsecret` - OAuth2 client secret -- `url` - Authentication server URL -- `serviceurls.AI_API_URL` - SAP AI Core API base URL +Service key contains: `clientid`, `clientsecret`, `url` (auth server), +`serviceurls.AI_API_URL` -⚠️ **Security Note**: Never commit credentials to version control. Use environment variables or secure vaults. +⚠️ **Important:** Never commit credentials. Use environment variables. ### Step 2: Get OAuth Token -SAP AI Core uses OAuth2 client credentials flow for authentication. - ```bash #!/bin/bash @@ -70,25 +86,15 @@ fi echo "✅ OAuth token obtained" ``` -**Key Points:** -- Use `printf` instead of `echo -n` for proper special character handling (e.g., `|`, `$`, `!` in client IDs) -- The token is a JWT containing tenant information (`subaccountid`, `zid`) -- Tokens typically expire after 12 hours +**Key Points:** Use `printf` (not `echo`) for special characters. Tokens expire +after 12h. ### Step 3: Call SAP AI Core API -#### Endpoint Structure +**Endpoint:** +`https://{AI_API_URL}/v2/inference/deployments/{DEPLOYMENT_ID}/v2/completion` -``` -https://{AI_API_URL}/v2/inference/deployments/{DEPLOYMENT_ID}/v2/completion - ^^ ^^ - | | - Base path Orchestration v2 -``` - -⚠️ **Important**: Note the `/v2` appears **twice** - once as base path and once for the completion endpoint. - -#### Example API Call +> **Note:** The `/v2` appears **twice** (base path + completion endpoint). ```bash # Configuration @@ -142,88 +148,47 @@ curl --request POST \ "config": { "modules": { "prompt_templating": { - "prompt": { /* Prompt configuration */ }, - "model": { /* Model configuration */ } + "prompt": { + /* Prompt configuration */ + }, + "model": { + /* Model configuration */ + } } } } } ``` -### Prompt Configuration - -```json -"prompt": { - "template": [ - { - "role": "system" | "user" | "assistant" | "tool", - "content": "message text" - } - ], - "tools": [ /* Optional: function definitions */ ], - "response_format": { /* Optional: structured output */ } -} -``` +### Request Structure -### Model Configuration +The SAP AI Core v2 API uses a modular configuration structure with `prompt` +(messages, tools, response_format) and `model` (name, version, params) sections. +See complete working examples below for the full structure. -```json -"model": { - "name": "gpt-4o", // Model ID - "version": "latest", // Model version - "params": { // Optional parameters - "temperature": 0.7, - "max_tokens": 1000, - "top_p": 1.0, - "parallel_tool_calls": true - } -} -``` +> 💡 For detailed parameter documentation, see +> [API Reference](./API_REFERENCE.md#modelparams) --- -## Function Calling Example - -### Defining Tools +## Tool Calling Example -```json -"tools": [ - { - "type": "function", - "function": { - "name": "calculate_price", - "description": "Calculate total price for products", - "parameters": { - "type": "object", - "properties": { - "price_per_unit": { - "type": "number", - "description": "Price per unit in dollars" - }, - "quantity": { - "type": "number", - "description": "Number of units" - } - }, - "required": ["price_per_unit", "quantity"], - "additionalProperties": false - } - } - } -] -``` +For complete tool calling documentation including all models, parallel +execution, error handling, and best practices, see +[API Reference - Tool Calling](./API_REFERENCE.md#tool-calling-function-calling). ### ⚠️ Model-Specific Limitations -| Model | Multiple Tools Support | Notes | -|-------|----------------------|-------| -| **gpt-4o** | ✅ Yes | Full support for multiple function tools | -| **gpt-4.1-mini** | ✅ Yes | Full support for multiple function tools | -| **gemini-2.0-flash** | ⚠️ Limited | Only 1 function tool per request | -| **gemini-1.5-pro** | ⚠️ Limited | Only 1 function tool per request | -| **claude-3-sonnet** | ✅ Yes | Multiple tools, sequential execution | +| Model | Multiple Tools Support | Notes | +| -------------------- | ---------------------- | ------------------------------------ | +| **gpt-4o** | ✅ Yes | Full support for multiple tools | +| **gpt-4.1-mini** | ✅ Yes | Full support for multiple tools | +| **gemini-2.0-flash** | ⚠️ Limited | Only 1 tool per request | +| **gemini-1.5-pro** | ⚠️ Limited | Only 1 tool per request | +| **claude-3-sonnet** | ✅ Yes | Multiple tools, sequential execution | -**Gemini Limitation**: Multiple tools will be supported in the future. For now, only one tool per request is supported. +**Gemini Note**: Multiple tools supported in future. Currently: 1 tool per +request. --- @@ -390,84 +355,34 @@ echo "✅ Request completed" --- -## Common Issues and Solutions - -### 1. "Missing Tenant Id" Error - -**Problem**: HTTP 400 with "Missing Tenant Id" +## Common Issues -**Causes**: -- Token expired or invalid -- Wrong endpoint URL (missing `/v2` in path) -- Token not generated from service key (lacks tenant context) - -**Solution**: -- Generate fresh OAuth token using service key -- Verify endpoint URL includes both `/v2` paths -- Use `printf` for credential encoding (not `echo`) - -### 2. "Bad Credentials" Error - -**Problem**: OAuth token request fails with 401 - -**Causes**: -- Incorrect client ID or secret -- Special characters not properly encoded -- Wrong authentication URL - -**Solution**: -- Double-check credentials from service key -- Use `printf '%s:%s' "$ID" "$SECRET"` for encoding -- Verify authentication URL matches your region - -### 3. "Multiple Tools Not Supported" - -**Problem**: HTTP 400 - "Multiple tools are supported only when they are all search tools" - -**Causes**: -- Using Gemini model with multiple function tools -- Vertex AI limitation - -**Solution**: -- Use only 1 tool per request for Gemini models -- Or switch to OpenAI models (gpt-4o, gpt-4.1-mini) -- Or combine multiple tools into one +| Error | Cause | Solution | +| -------------------- | ------------------------------------------------------- | ---------------------------------------------- | +| Missing Tenant Id | Expired token, wrong endpoint, invalid token | Regenerate token, verify `/v2` paths | +| Bad Credentials | Wrong client ID/secret, bad Base64 encoding | Check credentials, use `printf` not `echo` | +| Deployment Not Found | Wrong deployment ID, wrong region, wrong resource group | Verify deployment exists, check resource group | +| Multiple Tools Error | Gemini model with >1 tool | Use 1 tool OR switch to OpenAI/Claude models | --- ## Debugging Tips -### Enable Verbose Output +**Verbose output:** ```bash -curl --verbose \ - --fail-with-body \ - --show-error \ - ... +curl --verbose --fail-with-body --show-error ... ``` -This shows: -- Full request headers -- Response headers -- Connection details -- HTTP status codes - -### Check Token Validity - -Decode JWT token (without verification): +**Decode JWT token:** ```bash echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d | jq . ``` -Look for: -- `exp` - Expiration timestamp -- `subaccountid` - Tenant information -- `scope` - Permissions +Check: `exp` (expiration), `subaccountid`, `scope` -### Test with Minimal Request - -Start with simplest possible request: +**Minimal test request:** ```json { @@ -475,14 +390,9 @@ Start with simplest possible request: "modules": { "prompt_templating": { "prompt": { - "template": [ - {"role": "user", "content": "Hello"} - ] + "template": [{ "role": "user", "content": "Hello" }] }, - "model": { - "name": "gpt-4o", - "version": "latest" - } + "model": { "name": "gpt-4o", "version": "latest" } } } } @@ -493,27 +403,11 @@ Start with simplest possible request: ## Security Best Practices -1. **Never commit credentials** - - Use `.gitignore` for scripts with credentials - - Use environment variables: `CLIENT_ID="${CLIENT_ID}"` - - Use secret management tools in production - -2. **Rotate credentials regularly** - - Generate new service keys periodically - - Revoke old service keys - -3. **Use HTTPS only** - - All endpoints use HTTPS - - Verify SSL certificates (curl does this by default) - -4. **Store tokens securely** - - Tokens are sensitive (12-hour validity) - - Don't log full tokens - - Clear tokens after use - -5. **Limit token scope** - - Use dedicated service keys per application - - Apply principle of least privilege +1. Never commit credentials (use `.gitignore` + env vars) +2. Rotate credentials regularly +3. Use HTTPS only (curl verifies SSL by default) +4. Store tokens securely (12h validity, don't log) +5. Limit token scope (dedicated keys per app) --- @@ -521,31 +415,18 @@ Start with simplest possible request: - [SAP AI Core Documentation](https://help.sap.com/docs/sap-ai-core) - [Orchestration Service API Reference](https://help.sap.com/docs/sap-ai-core/orchestration) -- [Function Calling Guide](https://help.sap.com/docs/sap-ai-core/function-calling) +- [Tool Calling Guide](https://help.sap.com/docs/sap-ai-core/function-calling) --- -## Example Script Location - -A complete working script is available at: -``` -examples/working-curl-example.sh -``` +## TypeScript Examples -**Remember to replace placeholder credentials before running!** +See `examples/` directory: `example-generate-text.ts`, +`example-streaming-chat.ts`, `example-chat-completion-tool.ts`, +`example-image-recognition.ts`, `example-data-masking.ts`. More in +[README](./README.md#basic-usage). --- -## Summary - -This guide covers: -- ✅ OAuth2 authentication flow -- ✅ Proper credential encoding -- ✅ Orchestration v2 API structure -- ✅ Function calling setup -- ✅ Model-specific limitations -- ✅ Error handling and debugging -- ✅ Security best practices - -For production use, consider using the TypeScript provider package instead of manual curl calls for better error handling and type safety. - +For production, use the TypeScript provider package for better error handling +and type safety. diff --git a/ENVIRONMENT_SETUP.md b/ENVIRONMENT_SETUP.md index b6d26b5..96a645c 100644 --- a/ENVIRONMENT_SETUP.md +++ b/ENVIRONMENT_SETUP.md @@ -1,80 +1,223 @@ # Environment Setup -## Setting up SAP_AI_SERVICE_KEY +Complete guide for setting up authentication and environment configuration for +the SAP AI Core Provider. -To use the SAP AI provider with environment variables, you need to set up the `SAP_AI_SERVICE_KEY` environment variable. +> **Quick Start:** For a shorter introduction, see the +> [README Quick Start](./README.md#quick-start). **API Details:** For +> configuration options, see +> [API Reference - SAPAIProviderSettings](./API_REFERENCE.md#sapaiprovidersettings). -### 1. Create a .env file +## Table of Contents + + + +- [Quick Setup (Local Development)](#quick-setup-local-development) + - [1️⃣ Get Your Service Key](#1-get-your-service-key) + - [2️⃣ Configure Environment](#2-configure-environment) + - [3️⃣ Use in Code](#3-use-in-code) + - [Running Examples](#running-examples) +- [SAP BTP Deployment](#sap-btp-deployment) +- [Advanced Configuration](#advanced-configuration) + - [Custom Resource Groups](#custom-resource-groups) + - [Custom Deployment IDs](#custom-deployment-ids) + - [Destination Configuration](#destination-configuration) +- [Troubleshooting](#troubleshooting) + - [❌ Authentication Failed (401)](#authentication-failed-401) + - [❌ Cannot Find Module 'dotenv'](#cannot-find-module-dotenv) + - [❌ Deployment Not Found (404)](#deployment-not-found-404) + - [✅ Verify Configuration](#verify-configuration) +- [Security Best Practices](#security-best-practices) +- [Related Documentation](#related-documentation) + + + +## Quick Setup (Local Development) + +> ⚠️ **v2.0+ Change:** Authentication uses `AICORE_SERVICE_KEY` environment +> variable (changed from `SAP_AI_SERVICE_KEY` in v1.x). + +### 1️⃣ Get Your Service Key + +1. Log into SAP BTP Cockpit +2. Navigate to your subaccount → AI Core service instance +3. Create or view a service key +4. Copy the complete JSON + +### 2️⃣ Configure Environment Create a `.env` file in your project root: +```bash +cp .env.example .env +``` + +Add your service key: + ```bash # .env -SAP_AI_SERVICE_KEY={"serviceurls":{"AI_API_URL":"https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com"},"appname":"your-app-name","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone","identityzoneid":"your-identity-zone-id","url":"https://your-auth-url.authentication.region.hana.ondemand.com","credential-type":"binding-secret"} +AICORE_SERVICE_KEY='{"serviceurls":{"AI_API_URL":"https://..."},"clientid":"...","clientsecret":"...","url":"https://...","credential-type":"binding-secret"}' ``` -### 2. Get your service key from SAP BTP +### 3️⃣ Use in Code + +```typescript +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +// Authentication is automatic via AICORE_SERVICE_KEY +const provider = createSAPAIProvider(); +const model = provider("gpt-4o"); +``` + +> 💡 **Key v2.0 changes:** Provider creation is synchronous (no `await`), no +> `serviceKey` parameter needed. + +### Running Examples + +All examples in `examples/` use this authentication method: + +```bash +npx tsx examples/example-generate-text.ts +npx tsx examples/example-streaming-chat.ts +``` + +--- + +## SAP BTP Deployment + +When deployed on SAP BTP with service bindings, authentication is **fully +automatic** via `VCAP_SERVICES`: + +```typescript +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +// No environment variables needed - uses VCAP_SERVICES binding +const provider = createSAPAIProvider(); +const model = provider("gpt-4o"); +``` + +**Authentication priority:** The SAP AI SDK checks credentials in this order: + +1. `AICORE_SERVICE_KEY` environment variable +2. `VCAP_SERVICES` (SAP BTP service binding) +3. Custom destination configuration -1. Go to your SAP BTP cockpit -2. Navigate to your subaccount -3. Find your AI Core service instance -4. Create or view a service key -5. Copy the entire JSON and replace the placeholder in your `.env` file +--- -### 3. Use in your code +## Advanced Configuration -With the environment variable set, you can now use the provider without passing the service key: +### Custom Resource Groups ```typescript -import { createSAPAIProvider } from '@mymediset/sap-ai-provider'; -import 'dotenv/config'; +const provider = createSAPAIProvider({ + resourceGroup: "production", // Default: "default" +}); +``` + +### Custom Deployment IDs + +```typescript +const provider = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", // Auto-resolved if omitted +}); +``` + +### Destination Configuration -// This will use SAP_AI_SERVICE_KEY from environment -const provider = await createSAPAIProvider( - serviceKey: process.env.SAP_AI_SERVICE_KEY -); +For advanced scenarios with custom HTTP destinations: -// Use with any model -const model = provider('gpt-4o'); +```typescript +const provider = createSAPAIProvider({ + destination: { + // Custom destination configuration + }, +}); ``` -### 4. Examples +--- + +## Troubleshooting + +### ❌ Authentication Failed (401) -All example files have been updated to use the environment variable approach: +**Symptoms:** "Invalid token", "Authentication failed", HTTP 401 -- `example-generate-text.ts` - Basic text generation -- `example-image-recognition.ts` - Image analysis with vision models -- `example-simple-chat-completion.ts` - Simple chat completion -- `example-chat-completion-tool.ts` - Advanced tool calling and debugging +**Solutions:** -Simply set your `SAP_AI_SERVICE_KEY` and run any example: +1. Verify `AICORE_SERVICE_KEY` is set: `echo $AICORE_SERVICE_KEY` +2. Validate JSON syntax (use a JSON validator) +3. Check service key hasn't expired in SAP BTP Cockpit +4. Ensure `import "dotenv/config";` is at the top of your entry file + +### ❌ Cannot Find Module 'dotenv' + +**Solution:** ```bash -npx tsx example-generate-text.ts -npx tsx example-image-recognition.ts +npm install dotenv ``` -### 5. Alternative: Direct service key +### ❌ Deployment Not Found (404) + +**Solutions:** + +1. Verify deployment is running in SAP BTP Cockpit +2. Check `resourceGroup` matches your deployment +3. Confirm model ID is available in your region -You can still pass the service key directly if needed: +### ✅ Verify Configuration + +Check environment variable is loaded: ```typescript -const provider = await createSAPAIProvider({ - serviceKey: '{"serviceurls":...}', // your service key JSON -}); +import "dotenv/config"; +console.log("Service key loaded:", !!process.env.AICORE_SERVICE_KEY); +``` + +Verify service key structure: + +```typescript +const key = JSON.parse(process.env.AICORE_SERVICE_KEY || "{}"); +console.log("OAuth URL:", key.url); +console.log("AI API URL:", key.serviceurls?.AI_API_URL); ``` -## Environment Variable Priority +**For complete troubleshooting guide:** +[Troubleshooting Guide](./TROUBLESHOOTING.md) + +--- + +## Security Best Practices + +🔒 **Protect Credentials:** + +- Never commit `.env` files to version control +- Add `.env` to `.gitignore` +- Use secrets management in production (AWS Secrets Manager, Azure Key Vault, + etc.) + +🔄 **Rotate Keys Regularly:** + +- Rotate service keys every 90 days +- Use separate keys for development and production + +🚫 **Avoid Logging Secrets:** + +- Never log `AICORE_SERVICE_KEY` values +- Redact credentials from error reports and crash logs + +✅ **Validate Configuration:** -The provider checks for credentials in this order: +- Check service key format before deployment +- Test authentication in staging environment first -1. `token` option (if provided) -2. `serviceKey` option (if provided) -3. `SAP_AI_SERVICE_KEY` environment variable -4. `SAP_AI_TOKEN` environment variable (for direct token) +--- -## Security Note +## Related Documentation -- Never commit your `.env` file to version control -- Add `.env` to your `.gitignore` file -- Use proper secrets management in production environments +- [README - Authentication](./README.md#authentication) - Quick authentication overview +- [API Reference - Configuration](./API_REFERENCE.md#sapaiprovidersettings) - Configuration + options +- [Migration Guide - Authentication](./MIGRATION_GUIDE.md#2-update-authentication) - + Authentication changes in v2.0 diff --git a/LICENSE.md b/LICENSE.md index 22dad73..07bd5f5 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,187 @@ -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and + distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the + copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with + that entity. For the purposes of this definition, "control" means (i) the + power, direct or indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (ii) ownership of fifty percent + (50%) or more of the outstanding shares, or (iii) beneficial ownership of + such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation source, and + configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or + translation of a Source form, including but not limited to compiled object + code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that is + included in or attached to the work (an example is provided in the Appendix + below). + + "Derivative Works" shall mean any work, whether in Source or Object form, + that is based on (or derived from) the Work and for which the editorial + revisions, annotations, elaborations, or other modifications represent, as a + whole, an original work of authorship. For the purposes of this License, + Derivative Works shall not include works that remain separable from, or + merely link (or bind by name) to the interfaces of, the Work and Derivative + Works thereof. + + "Contribution" shall mean any work of authorship, including the original + version of the Work and any modifications or additions to that Work or + Derivative Works thereof, that is intentionally submitted to Licensor for + inclusion in the Work by the copyright owner or by an individual or Legal + Entity authorized to submit on behalf of the copyright owner. For the + purposes of this definition, "submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed by, + or on behalf of, the Licensor for the purpose of discussing and improving the + Work, but excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable copyright license to + reproduce, prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute the Work and such Derivative Works in Source or + Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, + each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, + no-charge, royalty-free, irrevocable (except as stated in this section) + patent license to make, have made, use, offer to sell, sell, import, and + otherwise transfer the Work, where such license applies only to those patent + claims licensable by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) with the + Work to which such Contribution(s) was submitted. If You institute patent + litigation against any entity (including a cross-claim or counterclaim in a + lawsuit) alleging that the Work or a Contribution incorporated within the + Work constitutes direct or contributory patent infringement, then any patent + licenses granted to You under this License for that Work shall terminate as + of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, and in + Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy + of this License; and + + (b) You must cause any modified files to carry prominent notices stating that + You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy of + the attribution notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative Works, in at least + one of the following places: within a NOTICE text file distributed as part of + the Derivative Works; within the Source form or documentation, if provided + along with the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices normally appear. + The contents of the NOTICE file are for informational purposes only and do + not modify the License. You may add Your own attribution notices within + Derivative Works that You distribute, alongside or as an addendum to the + NOTICE text from the Work, provided that such additional attribution notices + cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated in + this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to the + Licensor shall be under the terms and conditions of this License, without any + additional terms or conditions. Notwithstanding the above, nothing herein + shall supersede or modify the terms of any separate license agreement you may + have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, except as + required for reasonable and customary use in describing the origin of the + Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in + writing, Licensor provides the Work (and each Contributor provides its + Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied, including, without limitation, any + warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or + FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining + the appropriateness of using or redistributing the Work and assume any risks + associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in + tort (including negligence), contract, or otherwise, unless required by + applicable law (such as deliberate and grossly negligent acts) or agreed to + in writing, shall any Contributor be liable to You for damages, including any + direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or inability + to use the Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all other + commercial damages or losses), even if such Contributor has been advised of + the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a fee for, + acceptance of support, warranty, indemnity, or other liability obligations + and/or rights consistent with this License. However, in accepting such + obligations, You may act only on Your own behalf and on Your sole + responsibility, not on behalf of any other Contributor, and only if You agree + to indemnify, defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason of your + accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2026 Jérôme Benoit + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 28207f0..20a7452 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -5,441 +5,807 @@ Guide for migrating between versions of the SAP AI Core Provider. ## Table of Contents - [Overview](#overview) -- [Version 1.0.x to 1.1.x](#version-10x-to-11x) +- [Version 3.x to 4.x (Breaking Changes)](#version-3x-to-4x-breaking-changes) + - [Summary of Changes](#summary-of-changes) + - [Who Is Affected?](#who-is-affected) + - [Migration Steps](#migration-steps) + - [1. Update Package](#1-update-package) + - [2. Update Type Imports (If Using Direct Provider Access)](#2-update-type-imports-if-using-direct-provider-access) + - [3. Update Stream Parsing (If Manually Parsing Streams)](#3-update-stream-parsing-if-manually-parsing-streams) + - [4. Update Finish Reason Access (If Accessing Directly)](#4-update-finish-reason-access-if-accessing-directly) + - [5. Update Usage Access (If Accessing Token Details)](#5-update-usage-access-if-accessing-token-details) + - [6. Update Warning Handling (If Checking Warnings)](#6-update-warning-handling-if-checking-warnings) + - [V3 Features Not Supported](#v3-features-not-supported) + - [Testing Your Migration](#testing-your-migration) + - [Rollback Strategy](#rollback-strategy) + - [Common Migration Issues](#common-migration-issues) + - [Issue: "Property 'textDelta' does not exist"](#issue-property-textdelta-does-not-exist) + - [Issue: "Cannot read property 'total' of undefined"](#issue-cannot-read-property-total-of-undefined) + - [Issue: TypeScript errors on LanguageModelV2 types](#issue-typescript-errors-on-languagemodelv2-types) + - [FAQ](#faq) +- [Version 2.x to 3.x (Breaking Changes)](#version-2x-to-3x-breaking-changes) + - [Summary of Changes](#summary-of-changes-1) + - [Migration Steps](#migration-steps-1) + - [1. Update Package](#1-update-package-1) + - [2. Update Error Handling](#2-update-error-handling) + - [3. SAP Error Metadata Access](#3-sap-error-metadata-access) + - [4. Automatic Retries](#4-automatic-retries) +- [Version 1.x to 2.x (Breaking Changes)](#version-1x-to-2x-breaking-changes) + - [Summary of Changes](#summary-of-changes-2) + - [Migration Steps](#migration-steps-2) + - [1. Update Package](#1-update-package-2) + - [2. Update Authentication](#2-update-authentication) + - [3. Update Code (Remove await)](#3-update-code-remove-await) + - [4. Verify Functionality](#4-verify-functionality) + - [5. Optional: Adopt New Features](#5-optional-adopt-new-features) - [Breaking Changes](#breaking-changes) + - [Version 3.0.x](#version-30x) + - [Version 2.0.x](#version-20x) - [Deprecations](#deprecations) + - [Manual OAuth2 Token Management (Removed in v2.0)](#manual-oauth2-token-management-removed-in-v20) - [New Features](#new-features) + - [2.0.x Features](#20x-features) + - [1. SAP AI SDK Integration](#1-sap-ai-sdk-integration) + - [2. Data Masking (DPI)](#2-data-masking-dpi) + - [3. Content Filtering](#3-content-filtering) + - [4. Response Format Control](#4-response-format-control) + - [5. Default Settings](#5-default-settings) + - [6. Enhanced Streaming & Error Handling](#6-enhanced-streaming--error-handling) - [API Changes](#api-changes) + - [Added APIs (v2.0+)](#added-apis-v20) + - [Modified APIs](#modified-apis) + - [Removed APIs](#removed-apis) - [Migration Checklist](#migration-checklist) - ---- + - [Upgrading from 2.x to 3.x](#upgrading-from-2x-to-3x) + - [Upgrading from 1.x to 2.x](#upgrading-from-1x-to-2x) + - [Testing Checklist](#testing-checklist) +- [Common Migration Issues](#common-migration-issues-1) +- [Rollback Instructions](#rollback-instructions) + - [Rollback to 2.x](#rollback-to-2x) + - [Rollback to 1.x](#rollback-to-1x) + - [Verify Installation](#verify-installation) + - [Clear Cache](#clear-cache) +- [Getting Help](#getting-help) +- [Related Documentation](#related-documentation) ## Overview -This guide helps you migrate your application when upgrading to newer versions of the SAP AI Core Provider. It covers breaking changes, deprecations, and new features. +This guide helps you migrate your application when upgrading to newer versions +of the SAP AI Core Provider. It covers breaking changes, deprecations, and new +features. --- -## Version 1.0.x to 1.1.x +## Version 3.x to 4.x (Breaking Changes) + +**Version 4.0 migrates from LanguageModelV2 to LanguageModelV3 specification.** ### Summary of Changes **Breaking Changes:** -- None -**New Features:** -- Orchestration v2 API support -- Data masking with SAP Data Privacy Integration (DPI) -- `responseFormat` configuration -- `createSAPAIProviderSync` for synchronous initialization -- Enhanced streaming support -- `completionPath` option for custom endpoints +- Implements **LanguageModelV3** interface (replacing V2) +- Finish reason changed from `string` to `{ unified: string, raw?: string }` +- Usage structure now nested with detailed token breakdown +- Warning types updated to V3 format with `feature` field +- Stream structure uses explicit text block lifecycle events -**Improvements:** -- Better error messages -- Improved type definitions -- Enhanced JSDoc documentation -- Backward-compatible v1 API fallback +**Benefits:** + +- Future-proof compatibility with Vercel AI SDK 6+ +- Access to new V3 capabilities (agents, advanced streaming) +- Better type safety with structured result types +- Richer streaming with explicit block lifecycle +- Enhanced token usage metadata +- **New:** Text embeddings support (`EmbeddingModelV3`) for RAG and semantic search + +### Who Is Affected? + +| User Type | Impact | Action Required | +| ------------------------------------------------------- | -------------- | -------------------------------------------- | +| **High-level API users** (`generateText`, `streamText`) | ✅ Minimal | Verify code still works (likely no changes) | +| **Direct provider users** (type annotations) | ⚠️ Minor | Update import types from V2 to V3 | +| **Custom stream parsers** | ⚠️ Significant | Update stream parsing logic for V3 structure | ### Migration Steps #### 1. Update Package ```bash -npm install @mymediset/sap-ai-provider@latest +npm install @jerome-benoit/sap-ai-provider@^4.0.0 ``` -#### 2. No Code Changes Required +#### 2. Update Type Imports (If Using Direct Provider Access) -The 1.1.x release is fully backward compatible with 1.0.x. Your existing code will continue to work without modifications. +**Before (v3.x):** ```typescript -// ✅ This code works in both 1.0.x and 1.1.x -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY -}); +import type { LanguageModelV2 } from "@ai-sdk/provider"; -const model = provider('gpt-4o'); -const result = await generateText({ model, prompt: 'Hello!' }); +const model: LanguageModelV2 = provider("gpt-4o"); ``` -#### 3. Optional: Adopt New Features +**After (v4.x):** -##### Data Masking (DPI) +```typescript +import type { LanguageModelV3 } from "@ai-sdk/provider"; + +const model: LanguageModelV3 = provider("gpt-4o"); +``` -**Before (1.0.x):** No masking support +#### 3. Update Stream Parsing (If Manually Parsing Streams) + +**Before (v3.x - V2 Streams):** -**After (1.1.x):** Add DPI masking ```typescript -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - defaultSettings: { - masking: { - masking_providers: [{ - type: 'sap_data_privacy_integration', - method: 'anonymization', - entities: [ - { type: 'profile-email', replacement_strategy: { method: 'fabricated_data' } }, - { type: 'profile-person', replacement_strategy: { method: 'constant', value: 'REDACTED' } } - ] - }] - } +for await (const chunk of stream) { + if (chunk.type === "text-delta") { + process.stdout.write(chunk.textDelta); // Old property name } -}); +} ``` -##### Response Format - -**Before (1.0.x):** No explicit response format control +**After (v4.x - V3 Streams):** -**After (1.1.x):** Specify response format ```typescript -const model = provider('gpt-4o', { - responseFormat: { - type: 'json_schema', - json_schema: { - name: 'user_data', - schema: { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' } - } - } - } +for await (const chunk of stream) { + if (chunk.type === "text-delta") { + process.stdout.write(chunk.delta); // New property name } -}); + + // V3 adds structured block lifecycle + if (chunk.type === "text-start") { + console.log("Text block started:", chunk.id); + } + + if (chunk.type === "text-end") { + console.log("Text block ended:", chunk.id, chunk.text); + } +} ``` -##### Synchronous Provider Creation +#### 4. Update Finish Reason Access (If Accessing Directly) -**Before (1.0.x):** Only async initialization +**Before (v3.x):** -**After (1.1.x):** Synchronous option available ```typescript -// When you already have a token -const provider = createSAPAIProviderSync({ - token: 'your-oauth-token', - deploymentId: 'your-deployment' -}); +const result = await model.doGenerate(options); +if (result.finishReason === "stop") { + console.log("Completed normally"); +} ``` ---- +**After (v4.x):** -## Breaking Changes +```typescript +const result = await model.doGenerate(options); +if (result.finishReason.unified === "stop") { + console.log("Completed normally"); + console.log("Raw reason:", result.finishReason.raw); // Optional SAP-specific value +} +``` -### None in Current Versions +#### 5. Update Usage Access (If Accessing Token Details) -All versions maintain backward compatibility. No breaking changes have been introduced. +**Before (v3.x):** ---- +```typescript +const result = await generateText({ model, prompt }); +console.log("Input tokens:", result.usage.inputTokens); +console.log("Output tokens:", result.usage.outputTokens); +``` -## Deprecations +**After (v4.x):** -### API Endpoint v1 (Deprecated) +```typescript +const result = await generateText({ model, prompt }); +// V3 has nested structure with detailed breakdown +console.log("Input tokens:", result.usage.inputTokens.total); +console.log(" - No cache:", result.usage.inputTokens.noCache); +console.log(" - Cache read:", result.usage.inputTokens.cacheRead); +console.log(" - Cache write:", result.usage.inputTokens.cacheWrite); + +console.log("Output tokens:", result.usage.outputTokens.total); +console.log(" - Text:", result.usage.outputTokens.text); +console.log(" - Reasoning:", result.usage.outputTokens.reasoning); +``` -**Status:** Deprecated, decommission on **October 31, 2026** +> **Note**: SAP AI Core currently doesn't provide the detailed breakdown fields, +> so nested values may be `undefined`. -**What:** The v1 completion endpoint `POST /completion` +#### 6. Update Warning Handling (If Checking Warnings) -**Replacement:** Use v2 endpoint `POST /v2/completion` (default in 1.1.x) +**Before (v3.x):** -**Migration:** +```typescript +if (result.warnings) { + result.warnings.forEach((warning) => { + if (warning.type === "unsupported-setting") { + console.warn("Unsupported setting:", warning.setting); + } + }); +} +``` -The provider automatically uses v2 by default. To explicitly target v1 (not recommended): +**After (v4.x):** ```typescript -// Legacy v1 (deprecated) -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - baseURL: 'https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com', - completionPath: '/completion' // v1 endpoint -}); +if (result.warnings) { + result.warnings.forEach((warning) => { + if (warning.type === "unsupported") { + console.warn("Unsupported feature:", warning.feature); // New field name + console.warn("Details:", warning.details); + } + }); +} +``` -// ✅ Recommended: v2 (default) -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY - // Uses v2 by default -}); +### V3 Features Not Supported + +The following V3 capabilities are not currently supported by SAP AI Core: + +| Feature | Status | Behavior | +| ---------------------------- | ---------------- | ----------------------------------------------------- | +| **File content generation** | ❌ Not supported | Warnings emitted if requested | +| **Reasoning mode** | ❌ Not supported | Ignored with warning | +| **Source attribution** | ❌ Not supported | Not available in responses | +| **Tool approval requests** | ❌ Not supported | Not applicable | +| **Detailed token breakdown** | ⚠️ Partial | Nested structure present but details may be undefined | + +### Testing Your Migration + +1. **Run your tests:** + + ```bash + npm test + ``` + +2. **Check for TypeScript errors:** + + ```bash + npx tsc --noEmit + ``` + +3. **Test streaming if used:** + + ```typescript + import { streamText } from "ai"; + + const { textStream } = await streamText({ + model: provider("gpt-4o"), + prompt: "Count to 5", + }); + + for await (const text of textStream) { + process.stdout.write(text); + } + ``` + +### Rollback Strategy + +If you encounter issues, you can stay on v3.x: + +```bash +npm install @jerome-benoit/sap-ai-provider@^3.0.0 ``` ---- +Version 3.x will receive security updates for 6 months after v4.0.0 release. -## New Features +### Common Migration Issues -### 1.1.x Features +#### Issue: "Property 'textDelta' does not exist" -#### 1. Data Masking (DPI) +**Cause**: Accessing old V2 property name in stream chunks. -Automatically anonymize or pseudonymize sensitive information: +**Fix**: Change `textDelta` to `delta`: ```typescript -const model = provider('gpt-4o', { - masking: { - masking_providers: [{ - type: 'sap_data_privacy_integration', - method: 'anonymization', - entities: [ - { type: 'profile-email', replacement_strategy: { method: 'fabricated_data' } }, - { type: 'profile-person', replacement_strategy: { method: 'constant', value: 'REDACTED' } }, - { regex: '\\b[0-9]{4}-[0-9]{4}\\b', replacement_strategy: { method: 'constant', value: 'ID_REDACTED' } } - ], - allowlist: ['SAP', 'BTP'] - }] - } -}); +// ❌ Before +chunk.textDelta; + +// ✅ After +chunk.delta; ``` -#### 2. Response Format Control +#### Issue: "Cannot read property 'total' of undefined" -Specify desired response format: +**Cause**: Trying to access nested usage structure that doesn't exist in your +version. + +**Fix**: Optional chaining or fallback: ```typescript -// Text response (default when no tools) -const model1 = provider('gpt-4o', { - responseFormat: { type: 'text' } -}); +// ✅ Safe access +const inputTokens = result.usage.inputTokens?.total ?? result.usage.inputTokens; +``` -// JSON object response -const model2 = provider('gpt-4o', { - responseFormat: { type: 'json_object' } -}); +#### Issue: TypeScript errors on LanguageModelV2 types -// JSON schema response (structured output) -const model3 = provider('gpt-4o', { - responseFormat: { - type: 'json_schema', - json_schema: { - name: 'user_profile', - description: 'User profile data', - schema: { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - email: { type: 'string', format: 'email' } - }, - required: ['name'] - }, - strict: true - } - } -}); +**Cause**: Importing old V2 types. + +**Fix**: Update imports to V3: + +```typescript +// ❌ Before +import type { LanguageModelV2 } from "@ai-sdk/provider"; + +// ✅ After +import type { LanguageModelV3 } from "@ai-sdk/provider"; ``` -#### 3. Default Settings +### FAQ -Apply settings to all models created by a provider: +**Q: Do I need to change my code if I only use `generateText()` and +`streamText()`?** -```typescript -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - defaultSettings: { - modelParams: { - temperature: 0.7, - maxTokens: 2000 - }, - safePrompt: true, - masking: { /* DPI config */ } - } -}); +A: Probably not! The high-level APIs abstract most V2/V3 differences. Test your +code to confirm. -// All models inherit default settings -const model1 = provider('gpt-4o'); // Has temperature=0.7, maxTokens=2000 -const model2 = provider('claude-3.5-sonnet'); // Same defaults +**Q: Why did the finish reason become an object?** -// Override per model -const model3 = provider('gpt-4o', { - modelParams: { - temperature: 0.3 // Overrides default - } -}); -``` +A: V3 separates the standardized finish reason (`unified`) from +provider-specific values (`raw`), improving consistency across providers. -#### 4. Custom Completion Path +**Q: Will SAP AI Core support file generation or reasoning mode in the future?** -Target different endpoints: +A: We don't have information about SAP's roadmap. The provider is designed to +add support when SAP AI Core makes these features available. -```typescript -// Default: /inference/deployments/{id}/v2/completion -const provider1 = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY -}); +**Q: Can I use v3.x and v4.x in the same project?** -// Top-level v2 endpoint -const provider2 = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - baseURL: 'https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com', - completionPath: '/v2/completion' -}); +A: No, you can only use one version at a time. Choose based on your needs and +migrate when ready. -// Custom path -const provider3 = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - completionPath: '/custom/completion' -}); -``` +**Q: How long will v3.x be supported?** -#### 5. Enhanced Streaming +A: Version 3.x will receive security and critical bug fixes for 6 months after +v4.0.0 release. -Improved streaming support with better error handling: +--- -```typescript -import { streamText } from 'ai'; +## Version 2.x to 3.x (Breaking Changes) -const { textStream } = await streamText({ - model: provider('gpt-4o'), - prompt: 'Write a story' -}); +**Version 3.0 standardizes error handling to use Vercel AI SDK native error +types.** -for await (const textPart of textStream) { - process.stdout.write(textPart); -} +### Summary of Changes + +**Breaking Changes:** + +- `SAPAIError` class removed from exports +- All errors now use `APICallError` from `@ai-sdk/provider` +- Error handling is now fully compatible with AI SDK ecosystem + +**Benefits:** + +- Automatic retry with exponential backoff for rate limits and server errors +- Consistent error handling across all AI SDK providers +- Better integration with AI SDK tooling and frameworks +- Improved error messages with SAP-specific metadata preserved + +### Migration Steps + +#### 1. Update Package + +```bash +npm install @jerome-benoit/sap-ai-provider@3.0.0 ``` -#### 6. Improved Error Messages +#### 2. Update Error Handling -More detailed error information: +**Before (v2.x):** ```typescript -import { SAPAIError } from '@mymediset/sap-ai-provider'; +import { SAPAIError } from "@jerome-benoit/sap-ai-provider"; try { - await generateText({ model, prompt }); + const result = await generateText({ model, prompt }); } catch (error) { if (error instanceof SAPAIError) { + console.error("SAP AI Error:", error.code, error.message); + console.error("Request ID:", error.requestId); + } +} +``` + +**After (v3.x):** + +```typescript +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; + +try { + const result = await generateText({ model, prompt }); +} catch (error) { + if (error instanceof LoadAPIKeyError) { + // 401/403: Authentication issue + console.error("Auth Error:", error.message); + } else if (error instanceof NoSuchModelError) { + // 404: Model not found + console.error("Model not found:", error.modelId); + } else if (error instanceof APICallError) { + // Other API errors + console.error("API Error:", error.statusCode, error.message); + const sapError = JSON.parse(error.responseBody || "{}"); + console.error("Request ID:", sapError.error?.request_id); + } +} +``` + +#### 3. SAP Error Metadata Access + +SAP AI Core error metadata (request ID, code, location) is preserved in the +`responseBody` field: + +```typescript +catch (error) { + if (error instanceof APICallError) { + const sapError = JSON.parse(error.responseBody || '{}'); console.error({ - code: error.code, - message: error.message, - requestId: error.requestId, - location: error.location, - intermediateResults: error.intermediateResults // New in 1.1.x + statusCode: error.statusCode, + message: sapError.error?.message, + code: sapError.error?.code, + location: sapError.error?.location, + requestId: sapError.error?.request_id }); } } ``` +#### 4. Automatic Retries + +V3 now leverages AI SDK's built-in retry mechanism for transient errors (429, +500, 503). No code changes needed - retries happen automatically with +exponential backoff. + --- -## API Changes +## Version 1.x to 2.x (Breaking Changes) -### Added APIs +**Version 2.0 is a complete rewrite using the official SAP AI SDK +(@sap-ai-sdk/orchestration).** + +### Summary of Changes -#### `createSAPAIProviderSync` +**Breaking Changes:** + +- Provider creation is now **synchronous** (no more `await`) +- Authentication via `AICORE_SERVICE_KEY` environment variable (no more + `serviceKey` option) +- Uses official SAP AI SDK for authentication and API communication +- Requires Vercel AI Vercel AI SDK v5.0+ (v6.0+ recommended) + +**New Features:** + +- Complete SAP AI SDK v2 orchestration integration +- Data masking with SAP Data Privacy Integration (DPI) +- Content filtering (Azure Content Safety, Llama Guard) +- Grounding and translation modules support +- Helper functions for configuration (`buildDpiMaskingProvider`, + `buildAzureContentSafetyFilter`, etc.) +- `responseFormat` configuration for structured outputs +- Enhanced streaming support +- Better error messages with detailed context + +**Improvements:** -New synchronous provider creation function: +- Automatic authentication handling by SAP AI SDK +- Better type definitions with comprehensive JSDoc +- Improved error handling +- More reliable streaming + +### Migration Steps + +#### 1. Update Package + +```bash +npm install @jerome-benoit/sap-ai-provider@latest ai@latest +``` + +#### 2. Update Authentication + +**Key Changes:** + +- Environment variable: `SAP_AI_SERVICE_KEY` → `AICORE_SERVICE_KEY` +- Provider creation: Now synchronous (remove `await`) +- Token management: Automatic (SAP AI SDK handles OAuth2) + +**Complete setup instructions:** + +[Environment Setup Guide](./ENVIRONMENT_SETUP.md) + +#### 3. Update Code (Remove await) ```typescript -function createSAPAIProviderSync( - options: Omit & { token: string } -): SAPAIProvider +// v1.x: Async +const provider = await createSAPAIProvider({ + serviceKey: process.env.SAP_AI_SERVICE_KEY, +}); + +// v2.x: Synchronous +const provider = createSAPAIProvider(); + +// Rest of your code remains the same +const model = provider("gpt-4o"); +const result = await generateText({ model, prompt: "Hello!" }); ``` -#### `SAPAISettings.responseFormat` +#### 4. Verify Functionality -New property for controlling response format: +After updating authentication and removing `await` from provider creation, run +your tests and basic examples (`examples/`) to verify generation and streaming +work as expected. + +#### 5. Optional: Adopt New Features + +V2.0 introduces powerful features. See [API Reference](./API_REFERENCE.md) +for complete documentation and [examples/](./examples/) for working code. + +**Key new capabilities:** + +- **Data Masking (DPI)**: Anonymize sensitive data (emails, names, phone + numbers) - see [example-data-masking.ts](./examples/example-data-masking.ts) +- **Content Filtering**: Azure Content Safety, Llama Guard - see + [API Reference - Content Filtering](./API_REFERENCE.md#content-filtering) +- **Response Format**: Structured outputs with JSON schema - see + [API Reference - Response Format](./API_REFERENCE.md#response-format) +- **Default Settings**: Apply consistent settings across all models - see + [API Reference - Default Settings](./API_REFERENCE.md#default-settings) +- **Grounding & Translation**: Document grounding, language translation modules + +For detailed examples, see the [New Features](#new-features) section below. + +--- + +## Breaking Changes + +### Version 3.0.x + +| Change | Details | Migration | +| ---------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **SAPAIError Removed** | `SAPAIError` class no longer exported | Use `APICallError` from `@ai-sdk/provider` instead. SAP metadata preserved in `responseBody`. | +| **Error Types** | All errors now use AI SDK standard types | Import `APICallError` from `@ai-sdk/provider`, not from this package. | +| **Error Properties** | `error.code` → `error.statusCode`, `error.requestId` → parse from `error.responseBody` | Access SAP metadata via `JSON.parse(error.responseBody)`. | + +### Version 2.0.x + +| Change | Details | Migration | +| ------------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| **Authentication** | `serviceKey` option removed; now uses `AICORE_SERVICE_KEY` env var | Set environment variable, remove `serviceKey` from code. See [Environment Setup](./ENVIRONMENT_SETUP.md) | +| **Synchronous Provider** | `createSAPAIProvider()` no longer async | Remove `await` from provider creation | +| **Removed Options** | `token`, `completionPath`, `baseURL`, `headers`, `fetch` | Use SAP AI SDK automatic handling | +| **Token Management** | Manual OAuth2 removed | Automatic via SAP AI SDK | + +--- + +## Deprecations + +### Manual OAuth2 Token Management (Removed in v2.0) + +**Status:** Removed in v2.0\ +**Replacement:** Automatic authentication via SAP AI SDK with +`AICORE_SERVICE_KEY` environment variable\ +**Migration:** See [Environment Setup](./ENVIRONMENT_SETUP.md) for setup +instructions + +--- + +## New Features + +### 2.0.x Features + +V2.0 introduces several powerful features built on top of the official SAP AI +SDK. For detailed API documentation and complete examples, see +[API Reference](./API_REFERENCE.md). + +#### 1. SAP AI SDK Integration + +Full integration with `@sap-ai-sdk/orchestration` for authentication and API +communication. Authentication is now automatic via `AICORE_SERVICE_KEY` +environment variable or `VCAP_SERVICES` service binding. ```typescript -interface SAPAISettings { - // ... existing properties ... - responseFormat?: - | { type: 'text' } - | { type: 'json_object' } - | { type: 'json_schema'; json_schema: { ... } }; -} +const provider = createSAPAIProvider({ + resourceGroup: "production", + deploymentId: "d65d81e7c077e583", // Optional - auto-resolved if omitted +}); ``` -#### `SAPAISettings.masking` +**Complete documentation:** +[API Reference - SAPAIProviderSettings](./API_REFERENCE.md#sapaiprovidersettings) + +#### 2. Data Masking (DPI) -New property for data masking configuration: +Automatically anonymize or pseudonymize sensitive information (emails, phone +numbers, names) using SAP's Data Privacy Integration: ```typescript -interface SAPAISettings { - // ... existing properties ... - masking?: MaskingModuleConfig; -} +import { buildDpiMaskingProvider } from "@jerome-benoit/sap-ai-provider"; + +const dpiConfig = buildDpiMaskingProvider({ + method: "anonymization", + entities: ["profile-email", "profile-person", "profile-phone"], +}); ``` -#### `SAPAIProviderSettings.defaultSettings` +**Complete documentation:** +[API Reference - Data Masking](./API_REFERENCE.md#builddpimaskingproviderconfig), +[example-data-masking.ts](./examples/example-data-masking.ts) + +#### 3. Content Filtering -New property for provider-wide default settings: +Filter harmful content using Azure Content Safety or Llama Guard for +input/output safety: ```typescript -interface SAPAIProviderSettings { - // ... existing properties ... - defaultSettings?: SAPAISettings; -} +import { buildAzureContentSafetyFilter } from "@jerome-benoit/sap-ai-provider"; + +const provider = createSAPAIProvider({ + defaultSettings: { + filtering: { + input: { + filters: [ + buildAzureContentSafetyFilter("input", { + hate: "ALLOW_SAFE", + violence: "ALLOW_SAFE_LOW_MEDIUM", + }), + ], + }, + }, + }, +}); ``` -#### `SAPAIProviderSettings.completionPath` +**Complete documentation:** +[API Reference - Content Filtering](./API_REFERENCE.md#buildazurecontentsafetyfiltertype-config) + +#### 4. Response Format Control -New property for custom endpoint paths: +Specify structured output formats including JSON schema for deterministic +responses: ```typescript -interface SAPAIProviderSettings { - // ... existing properties ... - completionPath?: string; -} +// JSON object response +const model1 = provider("gpt-4o", { + responseFormat: { type: "json_object" }, +}); + +// JSON schema response +const model2 = provider("gpt-4o", { + responseFormat: { + type: "json_schema", + json_schema: { + name: "user_profile", + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + strict: true, + }, + }, +}); ``` -#### `SAPAIError.intermediateResults` +**Complete documentation:** +[API Reference - Response Format](./API_REFERENCE.md#response-format) + +#### 5. Default Settings -New property for v2 API intermediate results: +Apply consistent settings across all models created by a provider instance: ```typescript -class SAPAIError extends Error { - // ... existing properties ... - readonly intermediateResults?: unknown; -} +const provider = createSAPAIProvider({ + defaultSettings: { + modelParams: { temperature: 0.7, maxTokens: 2000 }, + masking: { + /* DPI config */ + }, + }, +}); + +// All models inherit default settings +const model1 = provider("gpt-4o"); // temperature=0.7 +const model2 = provider("gpt-4o", { + modelParams: { temperature: 0.3 }, // Override per model +}); ``` -### Modified APIs +**Complete documentation:** +[API Reference - Default Settings](./API_REFERENCE.md#default-settings) + +#### 6. Enhanced Streaming & Error Handling + +Improved streaming support with better error recovery and detailed error +messages including request IDs and error locations for debugging. + +**Complete documentation:** [README - Streaming](./README.md#streaming), +[API Reference - Error Handling](./API_REFERENCE.md#error-handling) + +--- + +## API Changes + +### Added APIs (v2.0+) + +| API | Purpose | Example | +| --------------------------------------- | ----------------------- | --------------------------------------------------------------------------------- | +| `buildDpiMaskingProvider()` | Data masking helper | `buildDpiMaskingProvider({ method: "anonymization", entities: [...] })` | +| `buildAzureContentSafetyFilter()` | Azure content filtering | `buildAzureContentSafetyFilter("input", { hate: "ALLOW_SAFE" })` | +| `buildLlamaGuard38BFilter()` | Llama Guard filtering | `buildLlamaGuard38BFilter("input")` | +| `buildDocumentGroundingConfig()` | Document grounding | `buildDocumentGroundingConfig({ filters: [...], placeholders: {...} })` | +| `buildTranslationConfig()` | Translation module | `buildTranslationConfig("input", { sourceLanguage: "de", targetLanguage: "en" })` | +| `SAPAISettings.responseFormat` | Structured outputs | `{ type: "json_schema", json_schema: {...} }` | +| `SAPAISettings.masking` | Masking configuration | `{ masking_providers: [...] }` | +| `SAPAISettings.filtering` | Content filtering | `{ input: { filters: [...] } }` | +| `SAPAIProviderSettings.defaultSettings` | Provider defaults | `{ defaultSettings: { modelParams: {...} } }` | -#### `createSAPAIProvider` +**See [API Reference](./API_REFERENCE.md) for complete documentation.** -Enhanced with new options: +### Modified APIs + +**`createSAPAIProvider`** - Now synchronous: ```typescript -// Before (1.0.x) -async function createSAPAIProvider(options?: { - serviceKey?: string | SAPAIServiceKey; - token?: string; - deploymentId?: string; - resourceGroup?: string; - baseURL?: string; - headers?: Record; - fetch?: typeof fetch; -}): Promise - -// After (1.1.x) - Backward compatible, with additions -async function createSAPAIProvider(options?: { - serviceKey?: string | SAPAIServiceKey; - token?: string; - deploymentId?: string; - resourceGroup?: string; - baseURL?: string; - completionPath?: string; // NEW - headers?: Record; - fetch?: typeof fetch; - defaultSettings?: SAPAISettings; // NEW -}): Promise +// v1.x: Async with serviceKey +await createSAPAIProvider({ + serviceKey, + token, + deploymentId, + baseURL, + headers, + fetch, +}); + +// v2.x: Synchronous with SAP AI SDK +createSAPAIProvider({ + resourceGroup, + deploymentId, + destination, + defaultSettings, +}); ``` +### Removed APIs + +- `serviceKey` option → Use `AICORE_SERVICE_KEY` env var +- `token` option → Automatic authentication +- `baseURL`, `completionPath`, `headers`, `fetch` → Handled by SAP AI SDK + --- ## Migration Checklist -### Upgrading from 1.0.x to 1.1.x - -- [ ] Update package: `npm install @mymediset/sap-ai-provider@latest` +### Upgrading from 2.x to 3.x + +- [ ] Update package: `npm install @jerome-benoit/sap-ai-provider@3.0.0` +- [ ] Replace `SAPAIError` imports with `APICallError` from `@ai-sdk/provider` +- [ ] Update error handling code to use `error.statusCode` instead of + `error.code` +- [ ] Update error metadata access to parse `error.responseBody` JSON for SAP + details +- [ ] Remove any custom retry logic (now automatic with AI SDK) +- [ ] Run tests to verify error handling works correctly +- [ ] Test automatic retry behavior with rate limits (429) and server errors + (500, 503) + +### Upgrading from 1.x to 2.x + +- [ ] Update packages: `npm install @jerome-benoit/sap-ai-provider@latest ai@latest` +- [ ] Set `AICORE_SERVICE_KEY` environment variable (remove `serviceKey` from + code) +- [ ] Remove `await` from `createSAPAIProvider()` calls (now synchronous) +- [ ] Remove `serviceKey`, `token`, `baseURL`, `completionPath` options from + provider settings +- [ ] Update masking configuration to use `buildDpiMaskingProvider()` helper +- [ ] Update filtering configuration to use helper functions if applicable - [ ] Run tests to verify existing functionality -- [ ] Review new features (masking, responseFormat, etc.) +- [ ] Review new features (content filtering, grounding, translation) - [ ] Consider adopting default settings for cleaner code -- [ ] Update documentation if using custom configurations -- [ ] Check if you want to migrate to v2 endpoint explicitly (already default) -- [ ] Consider adding data masking for sensitive data -- [ ] Review error handling to leverage new error details -- [ ] Update TypeScript types if using them directly +- [ ] Update documentation to reflect v2 API +- [ ] Update TypeScript imports if using advanced types ### Testing Checklist @@ -459,66 +825,41 @@ After migration: ## Common Migration Issues -### Issue 1: Type Errors After Upgrade - -**Problem:** TypeScript errors after upgrading +| Issue | Cause | Solution | +| --------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | +| | | | +| **Authentication failures** | Missing/incorrect env var | Verify `AICORE_SERVICE_KEY` is set. See [Environment Setup](./ENVIRONMENT_SETUP.md) | +| **Masking errors** | Incorrect configuration | Use `buildDpiMaskingProvider()` helper. See [example-data-masking.ts](./examples/example-data-masking.ts) | -**Solution:** Rebuild your project and update type definitions: +For detailed troubleshooting, see [Troubleshooting Guide](./TROUBLESHOOTING.md). -```bash -npm run clean -npm run build -npm run type-check -``` - -### Issue 2: Changed Default Behavior - -**Problem:** Different default behavior - -**Solution:** The defaults haven't changed. If you experience issues, explicitly set values: - -```typescript -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, - deploymentId: 'd65d81e7c077e583', // Explicit default - resourceGroup: 'default' // Explicit default -}); -``` +--- -### Issue 3: Masking Errors +## Rollback Instructions -**Problem:** Errors when using masking +If you need to rollback to a previous version: -**Solution:** Ensure your SAP AI Core instance supports DPI: +### Rollback to 2.x -```typescript -// Check if masking is available in your instance -try { - const model = provider('gpt-4o', { - masking: { /* config */ } - }); -} catch (error) { - console.error('Masking not available:', error); - // Fall back to non-masked model -} +```bash +npm install @jerome-benoit/sap-ai-provider@2.1.0 ``` ---- +> **Note:** Version 2.x exports `SAPAIError` class for error handling. -## Rollback Instructions - -If you need to rollback to a previous version: - -### Rollback to 1.0.x +### Rollback to 1.x ```bash -npm install @mymediset/sap-ai-provider@1.0.3 +npm install @jerome-benoit/sap-ai-provider@1.0.3 ai@5 ``` +> **Note:** Version 1.x uses a different authentication approach and async +> provider creation. + ### Verify Installation ```bash -npm list @mymediset/sap-ai-provider +npm list @jerome-benoit/sap-ai-provider ``` ### Clear Cache @@ -536,12 +877,12 @@ npm install If you encounter issues during migration: 1. **Check Documentation:** - - [README.md](./README.md) - - [API_REFERENCE.md](./API_REFERENCE.md) - - [TROUBLESHOOTING](./README.md#troubleshooting) section + - [README](./README.md) + - [API Reference](./API_REFERENCE.md) + - [Troubleshooting](./README.md#troubleshooting) section 2. **Search Issues:** - - [GitHub Issues](https://github.com/BITASIA/sap-ai-provider/issues) + - [GitHub Issues](https://github.com/jerome-benoit/sap-ai-provider/issues) 3. **Create New Issue:** - Include: Version numbers, error messages, code samples @@ -555,8 +896,10 @@ If you encounter issues during migration: ## Related Documentation -- [API_REFERENCE.md](./API_REFERENCE.md) - Complete API documentation -- [CHANGELOG.md](./CHANGELOG.md) - Full change history -- [README.md](./README.md) - Getting started guide -- [CONTRIBUTING.md](./CONTRIBUTING.md) - Contribution guidelines - +- [README](./README.md) - Getting started and feature overview +- [API Reference](./API_REFERENCE.md) - Complete API documentation for v2.x +- [Environment Setup](./ENVIRONMENT_SETUP.md) - Authentication setup for both + v1 and v2 +- [Architecture](./ARCHITECTURE.md) - Technical architecture (v2 + implementation) +- [Contributing Guide](./CONTRIBUTING.md) - Development and contribution guidelines diff --git a/README.md b/README.md index bd1f21d..7fb0b18 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,219 @@ # SAP AI Core Provider for Vercel AI SDK -[![npm](https://img.shields.io/npm/v/@mymediset/sap-ai-provider/latest?label=npm&color=blue)](https://www.npmjs.com/package/@mymediset/sap-ai-provider) +[![npm](https://img.shields.io/npm/v/@jerome-benoit/sap-ai-provider/latest?label=npm&color=blue)](https://www.npmjs.com/package/@jerome-benoit/sap-ai-provider) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Vercel AI SDK](https://img.shields.io/badge/Vercel%20AI%20SDK-6.0+-black.svg)](https://sdk.vercel.ai/docs) +[![Language Model](https://img.shields.io/badge/Language%20Model-V3-green.svg)](https://sdk.vercel.ai/docs/ai-sdk-core/provider-management) +[![Embedding Model](https://img.shields.io/badge/Embedding%20Model-V3-green.svg)](https://sdk.vercel.ai/docs/ai-sdk-core/embeddings) -A community provider for SAP AI Core that integrates seamlessly with the Vercel AI SDK. Built on top of the official **@sap-ai-sdk/orchestration** package, this provider enables you to use SAP's enterprise-grade AI models through the familiar Vercel AI SDK interface. - -## ⚠️ Breaking Changes in v2.0 - -Version 2.0 is a complete rewrite using the official SAP AI SDK. Key changes: - -- **Authentication**: Now uses `AICORE_SERVICE_KEY` environment variable (SAP AI SDK standard) -- **Provider creation**: Now synchronous - `createSAPAIProvider()` instead of `await createSAPAIProvider()` -- **No more `serviceKey` option**: Authentication is handled automatically by the SAP AI SDK -- **New helper functions**: Use `buildDpiMaskingProvider()`, `buildAzureContentSafetyFilter()` etc. from the SDK +A community provider for SAP AI Core that integrates seamlessly with the Vercel +AI SDK. Built on top of the official **@sap-ai-sdk/orchestration** package, this +provider enables you to use SAP's enterprise-grade AI models through the +familiar Vercel AI SDK interface. ## Table of Contents - [Features](#features) - [Quick Start](#quick-start) +- [Quick Reference](#quick-reference) - [Installation](#installation) +- [Provider Creation](#provider-creation) + - [Option 1: Factory Function (Recommended for Custom Configuration)](#option-1-factory-function-recommended-for-custom-configuration) + - [Option 2: Default Instance (Quick Start)](#option-2-default-instance-quick-start) - [Authentication](#authentication) - [Basic Usage](#basic-usage) + - [Text Generation](#text-generation) + - [Chat Conversations](#chat-conversations) + - [Streaming Responses](#streaming-responses) + - [Model Configuration](#model-configuration) + - [Embeddings](#embeddings) - [Supported Models](#supported-models) - [Advanced Features](#advanced-features) + - [Tool Calling](#tool-calling) + - [Multi-modal Input (Images)](#multi-modal-input-images) + - [Data Masking (SAP DPI)](#data-masking-sap-dpi) + - [Content Filtering](#content-filtering) + - [Document Grounding (RAG)](#document-grounding-rag) + - [Translation](#translation) + - [Provider Options (Per-Call Overrides)](#provider-options-per-call-overrides) - [Configuration Options](#configuration-options) - [Error Handling](#error-handling) +- [Troubleshooting](#troubleshooting) +- [Performance](#performance) +- [Security](#security) +- [Debug Mode](#debug-mode) - [Examples](#examples) -- [Migration from v1](#migration-from-v1) +- [Migration Guides](#migration-guides) + - [Upgrading from v3.x to v4.x](#upgrading-from-v3x-to-v4x) + - [Upgrading from v2.x to v3.x](#upgrading-from-v2x-to-v3x) + - [Upgrading from v1.x to v2.x](#upgrading-from-v1x-to-v2x) +- [Important Note](#important-note) - [Contributing](#contributing) +- [Resources](#resources) + - [Documentation](#documentation) + - [Community](#community) + - [Related Projects](#related-projects) - [License](#license) ## Features -- 🔐 **Automatic Authentication** - Uses SAP AI SDK's built-in credential handling -- 🎯 **Tool Calling Support** - Full function calling capabilities +- 🔐 **Simplified Authentication** - Uses SAP AI SDK's built-in credential + handling +- 🎯 **Tool Calling Support** - Full tool/function calling capabilities +- 🧠 **Reasoning-Safe by Default** - Assistant reasoning parts are not forwarded + unless enabled - 🖼️ **Multi-modal Input** - Support for text and image inputs -- 📡 **Streaming Support** - Real-time text generation +- 📡 **Streaming Support** - Real-time text generation with structured V3 blocks - 🔒 **Data Masking** - Built-in SAP DPI integration for privacy - 🛡️ **Content Filtering** - Azure Content Safety and Llama Guard support - 🔧 **TypeScript Support** - Full type safety and IntelliSense - 🎨 **Multiple Models** - Support for GPT-4, Claude, Gemini, Nova, and more +- ⚡ **Language Model V3** - Latest Vercel AI SDK specification with enhanced + streaming +- 📊 **Text Embeddings** - Generate vector embeddings for RAG and semantic search ## Quick Start ```bash -npm install @mymediset/sap-ai-provider ai +npm install @jerome-benoit/sap-ai-provider ai ``` ```typescript -import { createSAPAIProvider } from "@mymediset/sap-ai-provider"; +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; import { generateText } from "ai"; +import { APICallError } from "@ai-sdk/provider"; // Create provider (authentication via AICORE_SERVICE_KEY env var) const provider = createSAPAIProvider(); -// Generate text with gpt-4o -const result = await generateText({ - model: provider("gpt-4o"), - prompt: "Explain quantum computing in simple terms.", -}); +try { + // Generate text with gpt-4o + const result = await generateText({ + model: provider("gpt-4o"), + prompt: "Explain quantum computing in simple terms.", + }); -console.log(result.text); + console.log(result.text); +} catch (error) { + if (error instanceof APICallError) { + console.error("SAP AI Core API error:", error.message); + console.error("Status:", error.statusCode); + } else { + console.error("Unexpected error:", error); + } +} ``` +> **Note:** Requires `AICORE_SERVICE_KEY` environment variable. See +> [Environment Setup](./ENVIRONMENT_SETUP.md) for configuration. + +## Quick Reference + +| Task | Code Pattern | Documentation | +| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------- | +| **Install** | `npm install @jerome-benoit/sap-ai-provider ai` | [Installation](#installation) | +| **Auth Setup** | Add `AICORE_SERVICE_KEY` to `.env` | [Environment Setup](./ENVIRONMENT_SETUP.md) | +| **Create Provider** | `createSAPAIProvider()` or use `sapai` | [Provider Creation](#provider-creation) | +| **Text Generation** | `generateText({ model: provider("gpt-4o"), prompt })` | [Basic Usage](#text-generation) | +| **Streaming** | `streamText({ model: provider("gpt-4o"), prompt })` | [Streaming](#streaming-responses) | +| **Tool Calling** | `generateText({ tools: { myTool: tool({...}) } })` | [Tool Calling](#tool-calling) | +| **Error Handling** | `catch (error instanceof APICallError)` | [API Reference](./API_REFERENCE.md#error-handling--reference) | +| **Choose Model** | See 80+ models (GPT, Claude, Gemini, Llama) | [Models](./API_REFERENCE.md#models) | +| **Embeddings** | `embed({ model: provider.embedding("text-embedding-ada-002") })` | [Embeddings](#embeddings) | + ## Installation **Requirements:** Node.js 18+ and Vercel AI SDK 6.0+ ```bash -npm install @mymediset/sap-ai-provider ai +npm install @jerome-benoit/sap-ai-provider ai ``` Or with other package managers: ```bash # Yarn -yarn add @mymediset/sap-ai-provider ai +yarn add @jerome-benoit/sap-ai-provider ai # pnpm -pnpm add @mymediset/sap-ai-provider ai +pnpm add @jerome-benoit/sap-ai-provider ai ``` -## Authentication +## Provider Creation -The SAP AI SDK handles authentication automatically. You need to provide credentials in one of these ways: +You can create an SAP AI provider in two ways: -### On SAP BTP (Recommended) +### Option 1: Factory Function (Recommended for Custom Configuration) -When running on SAP BTP, bind an AI Core service instance to your application. The SDK will automatically detect the service binding from `VCAP_SERVICES`. +```typescript +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; -### Local Development +const provider = createSAPAIProvider({ + resourceGroup: "production", + deploymentId: "your-deployment-id", // Optional +}); +``` -Set the `AICORE_SERVICE_KEY` environment variable with your service key JSON: +### Option 2: Default Instance (Quick Start) -```bash -# .env -AICORE_SERVICE_KEY='{"serviceurls":{"AI_API_URL":"https://..."},"clientid":"...","clientsecret":"...","url":"..."}' +```typescript +import "dotenv/config"; // Load environment variables +import { sapai } from "@jerome-benoit/sap-ai-provider"; +import { generateText } from "ai"; + +// Use directly with auto-detected configuration +const result = await generateText({ + model: sapai("gpt-4o"), + prompt: "Hello!", +}); ``` -Get your service key from SAP BTP: +The `sapai` export provides a convenient default provider instance with +automatic configuration from environment variables or service bindings. + +## Authentication + +Authentication is handled automatically by the SAP AI SDK using the +`AICORE_SERVICE_KEY` environment variable. + +**Quick Setup:** -1. Go to your SAP BTP Cockpit -2. Navigate to your AI Core instance -3. Create a service key -4. Copy the JSON and set it as the environment variable +1. Create a `.env` file: `cp .env.example .env` +2. Add your SAP AI Core service key JSON to `AICORE_SERVICE_KEY` +3. Import in code: `import "dotenv/config";` + +**For complete setup instructions, SAP BTP deployment, troubleshooting, and +advanced scenarios, see the [Environment Setup Guide](./ENVIRONMENT_SETUP.md).** ## Basic Usage ### Text Generation -```typescript -import { createSAPAIProvider } from "@mymediset/sap-ai-provider"; -import { generateText } from "ai"; - -const provider = createSAPAIProvider(); +**Complete example:** +[examples/example-generate-text.ts](./examples/example-generate-text.ts) +```typescript const result = await generateText({ model: provider("gpt-4o"), prompt: "Write a short story about a robot learning to paint.", }); - console.log(result.text); ``` +**Run it:** `npx tsx examples/example-generate-text.ts` + ### Chat Conversations -```typescript -import { generateText } from "ai"; +**Complete example:** +[examples/example-simple-chat-completion.ts](./examples/example-simple-chat-completion.ts) + +> **Note:** Assistant `reasoning` parts are dropped by default. Set +> `includeReasoning: true` on the model settings if you explicitly want to +> forward them. +```typescript const result = await generateText({ model: provider("anthropic--claude-3.5-sonnet"), messages: [ @@ -140,11 +226,14 @@ const result = await generateText({ }); ``` +**Run it:** `npx tsx examples/example-simple-chat-completion.ts` + ### Streaming Responses -```typescript -import { streamText } from "ai"; +**Complete example:** +[examples/example-streaming-chat.ts](./examples/example-streaming-chat.ts) +```typescript const result = streamText({ model: provider("gpt-4o"), prompt: "Explain machine learning concepts.", @@ -155,10 +244,21 @@ for await (const delta of result.textStream) { } ``` +**Run it:** `npx tsx examples/example-streaming-chat.ts` + ### Model Configuration ```typescript +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +import { generateText } from "ai"; + +const provider = createSAPAIProvider(); + const model = provider("gpt-4o", { + // Optional: include assistant reasoning parts (chain-of-thought). + // Best practice is to keep this disabled. + includeReasoning: false, modelParams: { temperature: 0.3, maxTokens: 2000, @@ -172,52 +272,100 @@ const result = await generateText({ }); ``` +### Embeddings + +Generate vector embeddings for RAG (Retrieval-Augmented Generation), semantic +search, and similarity matching. + +**Complete example:** +[examples/example-embeddings.ts](./examples/example-embeddings.ts) + +```typescript +import "dotenv/config"; // Load environment variables +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +import { embed, embedMany } from "ai"; + +const provider = createSAPAIProvider(); + +// Single embedding +const { embedding } = await embed({ + model: provider.embedding("text-embedding-ada-002"), + value: "What is machine learning?", +}); + +// Multiple embeddings +const { embeddings } = await embedMany({ + model: provider.embedding("text-embedding-3-small"), + values: ["Hello world", "AI is amazing", "Vector search"], +}); +``` + +**Run it:** `npx tsx examples/example-embeddings.ts` + +**Common embedding models:** + +- `text-embedding-ada-002` - OpenAI Ada v2 (cost-effective) +- `text-embedding-3-small` - OpenAI v3 small (balanced) +- `text-embedding-3-large` - OpenAI v3 large (highest quality) + +> **Note:** Model availability depends on your SAP AI Core tenant configuration. + +For complete embedding API documentation, see +**[API Reference: Embeddings](./API_REFERENCE.md#embeddings)**. + ## Supported Models -### Azure OpenAI Models +This provider supports all models available through SAP AI Core Orchestration +service, including: -- `gpt-4o`, `gpt-4o-mini` -- `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano` -- `o1`, `o3`, `o3-mini`, `o4-mini` +**Popular models:** -### Google Vertex AI Models +- **OpenAI**: gpt-4o, gpt-4o-mini, gpt-4.1, o1, o3, o4-mini (recommended for + multi-tool apps) +- **Anthropic Claude**: anthropic--claude-3.5-sonnet, anthropic--claude-4-opus +- **Google Gemini**: gemini-2.5-pro, gemini-2.0-flash -- `gemini-2.0-flash`, `gemini-2.0-flash-lite` -- `gemini-2.5-flash`, `gemini-2.5-pro` +⚠️ **Important:** Google Gemini models have a 1 tool limit per request. -### AWS Bedrock Models +- **Amazon Nova**: amazon--nova-pro, amazon--nova-lite +- **Open Source**: mistralai--mistral-large-instruct, + meta--llama3.1-70b-instruct -- `anthropic--claude-3-haiku`, `anthropic--claude-3-sonnet`, `anthropic--claude-3-opus` -- `anthropic--claude-3.5-sonnet`, `anthropic--claude-3.7-sonnet` -- `anthropic--claude-4-sonnet`, `anthropic--claude-4-opus` -- `amazon--nova-pro`, `amazon--nova-lite`, `amazon--nova-micro`, `amazon--nova-premier` +> **Note:** Model availability depends on your SAP AI Core tenant configuration, +> region, and subscription. -### AI Core Open Source Models +**To discover available models in your environment:** -- `mistralai--mistral-large-instruct`, `mistralai--mistral-medium-instruct`, `mistralai--mistral-small-instruct` -- `cohere--command-a-reasoning` +```bash +curl "https:///v2/lm/deployments" -H "Authorization: Bearer $TOKEN" +``` -Model availability depends on your SAP AI Core subscription and region. +For complete model details, capabilities comparison, and limitations, see +**[API Reference: SAPAIModelId](./API_REFERENCE.md#sapaimodelid)**. ## Advanced Features +The following helper functions are exported by this package for convenient +configuration of SAP AI Core features. These builders provide type-safe +configuration for data masking, content filtering, grounding, and translation +modules. + ### Tool Calling -```typescript -import { generateText, tool } from "ai"; -import { z } from "zod"; +> **Note on Terminology:** This documentation uses "tool calling" (Vercel AI SDK +> convention), equivalent to "function calling" in OpenAI documentation. Both +> terms refer to the same capability of models invoking external functions. -const weatherSchema = z.object({ - location: z.string(), -}); +📖 **Complete guide:** +[API Reference - Tool Calling](./API_REFERENCE.md#tool-calling-function-calling)\ +**Complete example:** +[examples/example-chat-completion-tool.ts](./examples/example-chat-completion-tool.ts) +```typescript const weatherTool = tool({ description: "Get weather for a location", - inputSchema: weatherSchema, - execute: (args: z.infer) => { - const { location } = args; - return `Weather in ${location}: sunny, 72°F`; - }, + inputSchema: z.object({ location: z.string() }), + execute: (args) => `Weather in ${args.location}: sunny, 72°F`, }); const result = await generateText({ @@ -226,12 +374,20 @@ const result = await generateText({ tools: { getWeather: weatherTool }, maxSteps: 3, }); - -console.log(result.text); ``` +**Run it:** `npx tsx examples/example-chat-completion-tool.ts` + +⚠️ **Important:** Gemini models support only 1 tool per request. For multi-tool +applications, use GPT-4o, Claude, or Amazon Nova models. See +[API Reference - Tool Calling](./API_REFERENCE.md#tool-calling-function-calling) +for complete model comparison. + ### Multi-modal Input (Images) +**Complete example:** +[examples/example-image-recognition.ts](./examples/example-image-recognition.ts) + ```typescript const result = await generateText({ model: provider("gpt-4o"), @@ -247,49 +403,33 @@ const result = await generateText({ }); ``` +**Run it:** `npx tsx examples/example-image-recognition.ts` + ### Data Masking (SAP DPI) Use SAP's Data Privacy Integration to mask sensitive data: +**Complete example:** +[examples/example-data-masking.ts](./examples/example-data-masking.ts)\ +**Complete documentation:** +[API Reference - Data Masking](./API_REFERENCE.md#builddpimaskingproviderconfig) + ```typescript -import { - createSAPAIProvider, - buildDpiMaskingProvider, -} from "@mymediset/sap-ai-provider"; +import { buildDpiMaskingProvider } from "@jerome-benoit/sap-ai-provider"; const dpiConfig = buildDpiMaskingProvider({ method: "anonymization", - entities: [ - "profile-email", - "profile-person", - { - type: "profile-phone", - replacement_strategy: { method: "constant", value: "REDACTED" }, - }, - ], -}); - -const provider = createSAPAIProvider({ - defaultSettings: { - masking: { - masking_providers: [dpiConfig], - }, - }, -}); - -const result = await generateText({ - model: provider("gpt-4o"), - prompt: "Email john@example.com about the meeting.", + entities: ["profile-email", "profile-person", "profile-phone"], }); ``` +**Run it:** `npx tsx examples/example-data-masking.ts` + ### Content Filtering ```typescript -import { - createSAPAIProvider, - buildAzureContentSafetyFilter, -} from "@mymediset/sap-ai-provider"; +import "dotenv/config"; // Load environment variables +import { buildAzureContentSafetyFilter, createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; const provider = createSAPAIProvider({ defaultSettings: { @@ -307,42 +447,127 @@ const provider = createSAPAIProvider({ }); ``` -## Configuration Options +**Complete documentation:** +[API Reference - Content Filtering](./API_REFERENCE.md#buildazurecontentsafetyfiltertype-config) + +### Document Grounding (RAG) + +Ground LLM responses in your own documents using vector databases. -### Provider Settings +**Complete example:** +[examples/example-document-grounding.ts](./examples/example-document-grounding.ts)\ +**Complete documentation:** +[API Reference - Document Grounding](./API_REFERENCE.md#builddocumentgroundingconfigconfig) ```typescript -interface SAPAIProviderSettings { - resourceGroup?: string; // SAP AI Core resource group (default: 'default') - deploymentId?: string; // Specific deployment ID (auto-resolved if not set) - destination?: HttpDestinationOrFetchOptions; // Custom destination - defaultSettings?: SAPAISettings; // Default settings for all models -} +const provider = createSAPAIProvider({ + defaultSettings: { + grounding: buildDocumentGroundingConfig({ + filters: [ + { + id: "vector-store-1", // Your vector database ID + data_repositories: ["*"], // Search all repositories + }, + ], + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }), + }, +}); + +// Queries are now grounded in your documents +const model = provider("gpt-4o"); ``` -### Model Settings +**Run it:** `npx tsx examples/example-document-grounding.ts` + +### Translation + +Automatically translate user queries and model responses. + +**Complete example:** +[examples/example-translation.ts](./examples/example-translation.ts)\ +**Complete documentation:** +[API Reference - Translation](./API_REFERENCE.md#buildtranslationconfigtype-config) ```typescript -interface SAPAISettings { - modelVersion?: string; // Model version (default: 'latest') - modelParams?: { - maxTokens?: number; // Maximum tokens to generate - temperature?: number; // Sampling temperature (0-2) - topP?: number; // Nucleus sampling (0-1) - frequencyPenalty?: number; // Frequency penalty (-2 to 2) - presencePenalty?: number; // Presence penalty (-2 to 2) - n?: number; // Number of completions - parallel_tool_calls?: boolean; // Enable parallel tool calls - }; - masking?: MaskingModule; // Data masking configuration - filtering?: FilteringModule; // Content filtering configuration -} +const provider = createSAPAIProvider({ + defaultSettings: { + translation: { + // Translate user input from German to English + input: buildTranslationConfig("input", { + sourceLanguage: "de", + targetLanguage: "en", + }), + // Translate model output from English to German + output: buildTranslationConfig("output", { + targetLanguage: "de", + }), + }, + }, +}); + +// Model handles German input/output automatically +const model = provider("gpt-4o"); ``` +**Run it:** `npx tsx examples/example-translation.ts` + +### Provider Options (Per-Call Overrides) + +Override constructor settings on a per-call basis using `providerOptions`. +Options are validated at runtime with Zod schemas. + +```typescript +import { generateText } from "ai"; + +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "Explain quantum computing", + providerOptions: { + "sap-ai": { + includeReasoning: true, + modelParams: { + temperature: 0.7, + maxTokens: 1000, + }, + }, + }, +}); +``` + +**Complete documentation:** +[API Reference - Provider Options](./API_REFERENCE.md#provider-options) + +## Configuration Options + +The provider and models can be configured with various settings for +authentication, model parameters, data masking, content filtering, and more. + +**Common Configuration:** + +- `name`: Provider name (default: `'sap-ai'`). Used as key in `providerOptions`/`providerMetadata`. +- `resourceGroup`: SAP AI Core resource group (default: 'default') +- `deploymentId`: Specific deployment ID (auto-resolved if not set) +- `modelParams`: Temperature, maxTokens, topP, and other generation parameters +- `masking`: SAP Data Privacy Integration (DPI) configuration +- `filtering`: Content safety filters (Azure Content Safety, Llama Guard) + +For complete configuration reference including all available options, types, and +examples, see +**[API Reference - Configuration](./API_REFERENCE.md#sapaiprovidersettings)**. + ## Error Handling +The provider uses standard Vercel AI SDK error types for consistent error +handling. + +**Quick Example:** + ```typescript -import { SAPAIError } from "@mymediset/sap-ai-provider"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; try { const result = await generateText({ @@ -350,100 +575,203 @@ try { prompt: "Hello world", }); } catch (error) { - if (error instanceof SAPAIError) { - console.error("Code:", error.code); - console.error("Location:", error.location); - console.error("Request ID:", error.requestId); + if (error instanceof LoadAPIKeyError) { + // 401/403: Authentication or permission issue + console.error("Authentication issue:", error.message); + } else if (error instanceof NoSuchModelError) { + // 404: Model or deployment not found + console.error("Model not found:", error.modelId); + } else if (error instanceof APICallError) { + // Other API errors (400, 429, 5xx, etc.) + console.error("API error:", error.statusCode, error.message); + // SAP-specific metadata in responseBody + const sapError = JSON.parse(error.responseBody ?? "{}"); + console.error("Request ID:", sapError.error?.request_id); } } ``` -## Examples +**Complete reference:** -Check out the [examples directory](./examples) for complete working examples: +- **[API Reference - Error Handling](./API_REFERENCE.md#error-handling-examples)** - + Complete examples with all error properties +- **[API Reference - HTTP Status Codes](./API_REFERENCE.md#http-status-code-reference)** - + Status code reference table +- **[Troubleshooting Guide](./TROUBLESHOOTING.md)** - Detailed solutions for + each error type -- [Simple Chat Completion](./examples/example-simple-chat-completion.ts) -- [Tool Calling](./examples/example-chat-completion-tool.ts) -- [Image Recognition](./examples/example-image-recognition.ts) -- [Text Generation](./examples/example-generate-text.ts) -- [Data Masking](./examples/example-data-masking.ts) -- [Streaming](./examples/example-streaming-chat.ts) +## Troubleshooting -## Migration from v1 +**Quick Reference:** -### Authentication +- **Authentication (401)**: Check `AICORE_SERVICE_KEY` or `VCAP_SERVICES` +- **Model not found (404)**: Confirm tenant/region supports the model ID +- **Rate limit (429)**: Automatic retry with exponential backoff +- **Streaming**: Iterate `textStream` correctly; don't mix `generateText` and + `streamText` -**Before (v1):** +**For comprehensive troubleshooting, see +[Troubleshooting Guide](./TROUBLESHOOTING.md)** with detailed solutions for: -```typescript -const provider = await createSAPAIProvider({ - serviceKey: process.env.SAP_AI_SERVICE_KEY, -}); -``` +- [Authentication Failed (401)](./TROUBLESHOOTING.md#problem-authentication-failed-or-401-errors) +- [Model Not Found (404)](./TROUBLESHOOTING.md#problem-404-modeldeployment-not-found) +- [Rate Limit (429)](./TROUBLESHOOTING.md#problem-429-rate-limit-exceeded) +- [Server Errors (500-504)](./TROUBLESHOOTING.md#problem-500502503504-server-errors) +- [Streaming Issues](./TROUBLESHOOTING.md#streaming-issues) +- [Tool Calling Problems](./TROUBLESHOOTING.md#tool-calling-issues) -**After (v2):** +Error code reference table: +[API Reference - HTTP Status Codes](./API_REFERENCE.md#http-status-code-reference) -```typescript -// Set AICORE_SERVICE_KEY env var instead -const provider = createSAPAIProvider(); -``` +## Performance -### Masking Configuration +- Prefer streaming (`streamText`) for long outputs to reduce latency and memory. +- Tune `modelParams` carefully: lower `temperature` for deterministic results; + set `maxTokens` to expected response size. +- Use `defaultSettings` at provider creation for shared knobs across models to + avoid per-call overhead. +- Avoid unnecessary history: keep `messages` concise to reduce prompt size and + cost. -**Before (v1):** +## Security -```typescript -const dpiMasking = { - type: "sap_data_privacy_integration", - method: "anonymization", - entities: [{ type: "profile-email" }], -}; -``` +- Do not commit `.env` or credentials; use environment variables and secrets + managers. +- Treat `AICORE_SERVICE_KEY` as sensitive; avoid logging it or including in + crash reports. +- Mask PII with DPI: configure `masking.masking_providers` using + `buildDpiMaskingProvider()`. +- Validate and sanitize tool outputs before executing any side effects. -**After (v2):** +## Debug Mode -```typescript -import { buildDpiMaskingProvider } from "@mymediset/sap-ai-provider"; +- Use the curl guide `CURL_API_TESTING_GUIDE.md` to diagnose raw API behavior + independent of the SDK. +- Log request IDs from `error.responseBody` (parse JSON for `request_id`) to + correlate with backend traces. +- Temporarily enable verbose logging in your app around provider calls; redact + secrets. -const dpiMasking = buildDpiMaskingProvider({ - method: "anonymization", - entities: ["profile-email"], -}); -``` +## Examples -### Provider is now synchronous +The `examples/` directory contains complete, runnable examples demonstrating key +features: -**Before (v1):** +| Example | Description | Key Features | +| ----------------------------------- | --------------------------- | --------------------------------------- | +| `example-generate-text.ts` | Basic text generation | Simple prompts, synchronous generation | +| `example-simple-chat-completion.ts` | Simple chat conversation | System messages, user prompts | +| `example-chat-completion-tool.ts` | Tool calling with functions | Weather API tool, function execution | +| `example-streaming-chat.ts` | Streaming responses | Real-time text generation, SSE | +| `example-image-recognition.ts` | Multi-modal with images | Vision models, image analysis | +| `example-data-masking.ts` | Data privacy integration | DPI masking, anonymization | +| `example-document-grounding.ts` | Document grounding (RAG) | Vector store, retrieval-augmented gen | +| `example-translation.ts` | Input/output translation | Multi-language support, SAP translation | +| `example-embeddings.ts` | Text embeddings | Vector generation, semantic similarity | -```typescript -const provider = await createSAPAIProvider({ serviceKey }); +**Running Examples:** + +```bash +npx tsx examples/example-generate-text.ts ``` -**After (v2):** +> **Note:** Examples require `AICORE_SERVICE_KEY` environment variable. See +> [Environment Setup](./ENVIRONMENT_SETUP.md) for configuration. -```typescript -const provider = createSAPAIProvider(); -``` +## Migration Guides + +### Upgrading from v3.x to v4.x + +Version 4.0 migrates from **LanguageModelV2** to **LanguageModelV3** +specification (AI SDK 6.0+). **See the +[Migration Guide](./MIGRATION_GUIDE.md#version-3x-to-4x-breaking-changes) for +complete upgrade instructions.** + +**Key changes:** + +- **Finish Reason**: Changed from string to object + (`result.finishReason.unified`) +- **Usage Structure**: Nested format with detailed token breakdown + (`result.usage.inputTokens.total`) +- **Stream Events**: Structured blocks (`text-start`, `text-delta`, `text-end`) + instead of simple deltas +- **Warning Types**: Updated format with `feature` field for categorization + +**Impact by user type:** + +- High-level API users (`generateText`/`streamText`): ✅ Minimal impact (likely + no changes) +- Direct provider users: ⚠️ Update type imports (`LanguageModelV2` → + `LanguageModelV3`) +- Custom stream parsers: ⚠️ Update parsing logic for V3 structure + +### Upgrading from v2.x to v3.x + +Version 3.0 standardizes error handling to use Vercel AI SDK native error types. +**See the [Migration Guide](./MIGRATION_GUIDE.md#v2x--v30) for complete upgrade +instructions.** + +**Key changes:** + +- `SAPAIError` removed → Use `APICallError` from `@ai-sdk/provider` +- Error properties: `error.code` → `error.statusCode` +- Automatic retries for rate limits (429) and server errors (5xx) + +### Upgrading from v1.x to v2.x + +Version 2.0 uses the official SAP AI SDK. **See the +[Migration Guide](./MIGRATION_GUIDE.md#v1x--v20) for complete upgrade +instructions.** + +**Key changes:** + +- Authentication via `AICORE_SERVICE_KEY` environment variable +- Synchronous provider creation: `createSAPAIProvider()` (no await) +- Helper functions from SAP AI SDK + +**For detailed migration instructions with code examples, see the +[complete Migration Guide](./MIGRATION_GUIDE.md).** ## Important Note -> **Third-Party Provider**: This SAP AI Core provider (`@mymediset/sap-ai-provider`) is developed and maintained by mymediset, not by SAP SE. While it uses the official SAP AI SDK and integrates with SAP AI Core services, it is not an official SAP product. +> **Third-Party Provider**: This SAP AI Core provider +> (`@jerome-benoit/sap-ai-provider`) is developed and maintained by jerome-benoit, not +> by SAP SE. While it uses the official SAP AI SDK and integrates with SAP AI +> Core services, it is not an official SAP product. ## Contributing -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. +We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) +for details. -## License +## Resources + +### Documentation -Apache License 2.0 - see [LICENSE](LICENSE.md) for details. +- [Migration Guide](./MIGRATION_GUIDE.md) - Version upgrade instructions (v1.x → + v2.x → v3.x → v4.x) +- [API Reference](./API_REFERENCE.md) - Complete API documentation with all + types and functions +- [Environment Setup](./ENVIRONMENT_SETUP.md) - Authentication and configuration + setup +- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions +- [Architecture](./ARCHITECTURE.md) - Internal architecture, design decisions, + and request flows +- [cURL API Testing Guide](./CURL_API_TESTING_GUIDE.md) - Direct API testing for + debugging -## Support +### Community -- 📖 [Documentation](https://github.com/BITASIA/sap-ai-provider) -- 🐛 [Issue Tracker](https://github.com/BITASIA/sap-ai-provider/issues) +- 🐛 [Issue Tracker](https://github.com/jerome-benoit/sap-ai-provider/issues) - Report + bugs, request features, and ask questions -## Related +### Related Projects - [Vercel AI SDK](https://sdk.vercel.ai/) - The AI SDK this provider extends - [SAP AI SDK](https://sap.github.io/ai-sdk/) - Official SAP Cloud SDK for AI -- [SAP AI Core Documentation](https://help.sap.com/docs/ai-core) - Official SAP AI Core docs +- [SAP AI Core Documentation](https://help.sap.com/docs/ai-core) - Official SAP + AI Core docs + +## License + +Apache License 2.0 - see [LICENSE](./LICENSE.md) for details. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..9eca967 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,472 @@ +# Troubleshooting Guide + +This guide helps diagnose and resolve common issues when using the SAP AI Core +Provider. + +## Quick Reference + +| Issue | Section | +| --------------------- | --------------------------------------------------------------------- | +| 401 Unauthorized | [Authentication Issues](#problem-authentication-failed-or-401-errors) | +| 403 Forbidden | [Authentication Issues](#problem-403-forbidden) | +| 404 Not Found | [Model and Deployment Issues](#problem-404-modeldeployment-not-found) | +| 400 Bad Request | [API Errors](#problem-400-bad-request) | +| 429 Rate Limit | [API Errors](#problem-429-rate-limit-exceeded) | +| 500-504 Server Errors | [API Errors](#problem-500502503504-server-errors) | +| Tools not called | [Tool Calling Issues](#problem-tools-not-being-called) | +| Stream issues | [Streaming Issues](#problem-streaming-not-working-or-incomplete) | +| Slow responses | [Performance Issues](#problem-slow-response-times) | + +## Common Problems (Top 5) + +Quick solutions for the most frequent issues: + +1. **🔴 401 Unauthorized** + - **Cause:** Missing or invalid `AICORE_SERVICE_KEY` + - **Fix:** [Authentication Setup Guide](./ENVIRONMENT_SETUP.md) + - **ETA:** 2 minutes + +2. **🟠 404 Model Not Found** + - **Cause:** Model not available in your tenant/region + - **Fix:** [Model Availability Guide](#problem-404-modeldeployment-not-found) + - **ETA:** 5 minutes + +3. **🟡 429 Rate Limit Exceeded** + - **Cause:** Too many requests + - **Fix:** Automatic retry enabled (no action needed) + - **ETA:** Resolves automatically + +4. **🟢 Streaming Not Working** + - **Cause:** Incorrect iteration pattern + - **Fix:** [Streaming Guide](#problem-streaming-not-working-or-incomplete) + - **ETA:** 2 minutes + +5. **🔵 Tools Not Being Called** + - **Cause:** Vague tool descriptions + - **Fix:** [Tool Calling Guide](#problem-tools-not-being-called) + - **ETA:** 5 minutes + +**For other issues**, see the detailed [Table of Contents](#table-of-contents) +below. + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Common Problems (Top 5)](#common-problems-top-5) +- [Authentication Issues](#authentication-issues) + - [Problem: "Authentication failed" or 401 errors](#problem-authentication-failed-or-401-errors) + - [Problem: "Cannot find module 'dotenv'"](#problem-cannot-find-module-dotenv) + - [Problem: 403 Forbidden](#problem-403-forbidden) +- [API Errors](#api-errors) + - [Parsing SAP Error Metadata (v3.0.0+)](#parsing-sap-error-metadata-v300) + - [Problem: 400 Bad Request](#problem-400-bad-request) + - [Problem: 429 Rate Limit Exceeded](#problem-429-rate-limit-exceeded) + - [Problem: 500/502/503/504 Server Errors](#problem-500502503504-server-errors) +- [Model and Deployment Issues](#model-and-deployment-issues) + - [Problem: 404 Model/Deployment Not Found](#problem-404-modeldeployment-not-found) + - [Problem: Model doesn't support features](#problem-model-doesnt-support-features) +- [Streaming Issues](#streaming-issues) + - [Problem: Streaming not working or incomplete](#problem-streaming-not-working-or-incomplete) +- [Tool Calling Issues](#tool-calling-issues) + - [Problem: Tools not being called](#problem-tools-not-being-called) + - [Problem: Tool execution errors](#problem-tool-execution-errors) +- [Performance Issues](#performance-issues) + - [Problem: Slow response times](#problem-slow-response-times) + - [Problem: High token usage / costs](#problem-high-token-usage--costs) +- [Debugging Tools](#debugging-tools) + - [Enable Verbose Logging](#enable-verbose-logging) + - [Control SAP Cloud SDK Log Level](#control-sap-cloud-sdk-log-level) + - [Use cURL for Direct API Testing](#use-curl-for-direct-api-testing) + - [Check Token Validity](#check-token-validity) + - [Test with Minimal Request](#test-with-minimal-request) + - [Verify Configuration](#verify-configuration) +- [Getting Help](#getting-help) +- [Known Limitations](#known-limitations) + - [Streaming Response ID Is Client-Generated](#streaming-response-id-is-client-generated) +- [Related Documentation](#related-documentation) + +## Authentication Issues + +### Problem: "Authentication failed" or 401 errors + +**Symptoms:** HTTP 401, "Invalid token", provider fails to initialize + +**Solutions:** + +1. Verify `AICORE_SERVICE_KEY` environment variable is set and contains valid + JSON +2. **→ Complete setup guide:** [Environment Setup Guide](./ENVIRONMENT_SETUP.md) + +### Problem: "Cannot find module 'dotenv'" + +**Solution:** `npm install dotenv` and add `import "dotenv/config";` at top of +entry file + +### Problem: 403 Forbidden + +**Symptoms:** HTTP 403, "Insufficient permissions" + +**Solutions:** + +1. Verify service key has necessary permissions +2. Check `AI-Resource-Group` header matches deployment +3. Confirm SAP BTP account has SAP AI Core access +4. Check model entitlements in tenant + +## API Errors + +For a complete error code reference, see +[API Reference - Error Handling](./API_REFERENCE.md#error-handling--reference). + +### Parsing SAP Error Metadata (v3.0.0+) + +> **Architecture Details:** For OAuth2 authentication flow and token management, +> see +> [Architecture - Authentication System](./ARCHITECTURE.md#authentication-system). + +**v3.0.0 Breaking Change:** `SAPAIError` removed. Use `APICallError`, +`LoadAPIKeyError`, or `NoSuchModelError` from `@ai-sdk/provider`. + +**Quick example:** + +```typescript +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; + +try { + const result = await generateText({ model, prompt }); +} catch (error) { + if (error instanceof LoadAPIKeyError) { + // 401/403: Authentication issue + console.error("Auth error:", error.message); + } else if (error instanceof NoSuchModelError) { + // 404: Model not found + console.error("Model not found:", error.modelId); + } else if (error instanceof APICallError) { + // Other API errors + console.error("Status:", error.statusCode); + const sapError = JSON.parse(error.responseBody ?? "{}"); + console.error("Request ID:", sapError.error?.request_id); + } +} +``` + +**For complete error handling with all error properties and SAP metadata +fields**, see +[API Reference - Error Handling Examples](./API_REFERENCE.md#error-handling-examples). + +### Problem: 400 Bad Request + +**Common Causes:** Invalid model parameters (temperature, maxTokens), malformed +request, incompatible features + +**Solutions:** + +- Validate configuration against TypeScript types +- Check API Reference for valid parameter ranges +- Enable verbose logging to see exact request + +### Problem: 429 Rate Limit Exceeded + +**Solutions:** + +1. Provider has automatic retry with exponential backoff +2. Use `streamText` instead of `generateText` for long outputs +3. Batch requests, cache responses, reduce `maxTokens` + +### Problem: 500/502/503/504 Server Errors + +**Solutions:** + +1. Provider automatically retries with exponential backoff +2. Check SAP AI Core service status +3. Reduce request complexity: simplify prompts, remove optional features + +## Model and Deployment Issues + +### Problem: 404 Model/Deployment Not Found + +**Symptoms:** "Model not found", "Deployment not found", HTTP 404 + +**Solutions:** + +1. **Verify model availability** in your SAP AI Core tenant/region + ([supported models](./API_REFERENCE.md#sapaimodelid)) +2. **Check resource group:** + + ```typescript + const provider = createSAPAIProvider({ + resourceGroup: "default", // Must match deployment + }); + ``` + +3. **Verify deployment status:** Ensure deployment is running, check deployment + ID +4. **Test with known model:** Try `gpt-4o` - if it works, issue is + model-specific + +### Problem: Model doesn't support features + +**Example:** "Tool calling not supported", "Streaming not available" + +**Solutions:** + +1. Check model-specific documentation for limitations +2. Use `gpt-4o` or `gpt-4.1-mini` for full tool calling (Gemini limited to 1 + tool) +3. Remove unsupported features or use alternatives (JSON mode instead of + structured outputs) + +## Streaming Issues + +> **Architecture Details:** For streaming implementation and SSE flow diagrams, +> see +> [Architecture - Streaming Text Generation](./ARCHITECTURE.md#streaming-text-generation-sse-flow). + +### Problem: Streaming not working or incomplete + +**Symptoms:** No chunks, stream ends early, chunks appear all at once + +**Solutions:** + +1. **Iterate correctly:** + + ```typescript + import "dotenv/config"; // Load environment variables + import { streamText } from "ai"; + + const result = streamText({ + model: provider("gpt-4o"), + prompt: "Write a story", + }); + + for await (const chunk of result.textStream) { + process.stdout.write(chunk); + } + ``` + +2. **Don't mix:** Use `streamText` for streaming, `generateText` for complete + responses + +3. **Check buffering:** Set HTTP headers for streaming: + + ```typescript + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + ``` + +4. **Handle errors:** + + ```typescript + try { + for await (const chunk of result.textStream) { + process.stdout.write(chunk); + } + } catch (error) { + console.error("Stream error:", error); + } + ``` + +## Tool Calling Issues + +### Problem: Tools not being called + +**Symptoms:** Model doesn't use tools, generates text instead + +**Solutions:** + +1. **Improve descriptions:** Be specific in tool descriptions and parameter + descriptions + + ```typescript + const weatherTool = tool({ + description: "Get current weather for a specific location", + parameters: z.object({ + location: z.string().describe("City name, e.g., 'Tokyo'"), + }), + }); + ``` + +2. **Make prompt explicit:** "What's the weather in Tokyo? Use the weather tool + to check." + +3. **Check compatibility:** Gemini supports only 1 tool per request. Use + `gpt-4o` for multiple tools. + [Model limitations](./CURL_API_TESTING_GUIDE.md#tool-calling-example) + +### Problem: Tool execution errors + +**Solutions:** + +1. **Validate arguments:** Use schema validation before executing +2. **Handle errors gracefully:** Wrap execute in try-catch, return + `{ error: message }` +3. **Return structured data:** JSON-serializable only, avoid complex objects + +## Performance Issues + +### Problem: Slow response times + +**Solutions:** + +1. Use `streamText` for long outputs (faster perceived performance) +2. Optimize params: Set `maxTokens` to expected size, lower `temperature`, use + smaller models (`gpt-4o-mini`) +3. Reduce prompt size: Concise history, remove unnecessary context, summarize + periodically + +### Problem: High token usage / costs + +**Solutions:** + +1. Set appropriate `maxTokens` (estimate actual response length) +2. Optimize prompts: Be concise, remove redundancy, use system messages + effectively +3. Monitor usage: `console.log(result.usage)` + +## Debugging Tools + +### Enable Verbose Logging + +```bash +export DEBUG=sap-ai-provider:* +``` + +### Control SAP Cloud SDK Log Level + +The SAP AI SDK may emit informational messages (e.g., service key usage notices). +You can control the verbosity: + +#### Option 1: Via provider configuration (recommended) + +```typescript +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +const provider = createSAPAIProvider({ + logLevel: "warn", // 'error' | 'warn' | 'info' | 'debug' (default: 'warn') +}); +``` + +#### Option 2: Via environment variable + +```bash +export SAP_CLOUD_SDK_LOG_LEVEL=error # Suppress all but errors +``` + +Log levels: + +- `error` - Only critical errors +- `warn` - Errors and warnings (default) +- `info` - Include informational messages (e.g., "Using service key for local + testing") +- `debug` - Verbose SDK debugging + +**Note:** The `SAP_CLOUD_SDK_LOG_LEVEL` environment variable takes precedence +over the `logLevel` provider option. + +### Use cURL for Direct API Testing + +See [cURL API Testing Guide](./CURL_API_TESTING_GUIDE.md) for comprehensive +direct API testing. + +### Check Token Validity + +Decode JWT token: + +```bash +echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d | jq . +``` + +Check: `exp` (expiration), `subaccountid`, `scope` + +### Test with Minimal Request + +Start simple, add features gradually: + +```typescript +import "dotenv/config"; // Load environment variables +import { generateText } from "ai"; +import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; + +const provider = createSAPAIProvider(); +const result = await generateText({ + model: provider("gpt-4o"), + prompt: "Hello", +}); +console.log(result.text); +``` + +### Verify Configuration + +```typescript +import "dotenv/config"; // Load environment variables +console.log("Node:", process.version); +console.log("Service key set:", !!process.env.AICORE_SERVICE_KEY); + +const key = JSON.parse(process.env.AICORE_SERVICE_KEY || "{}"); +console.log("OAuth URL:", key.url); +console.log("AI API URL:", key.serviceurls?.AI_API_URL); +``` + +## Getting Help + +If issues persist: + +1. **Check documentation:** [README](./README.md), + [API Reference](./API_REFERENCE.md), + [Environment Setup](./ENVIRONMENT_SETUP.md) +2. **Review examples:** Compare your code with `examples/` directory + +3. **Open an issue:** + [GitHub Issues](https://github.com/jerome-benoit/sap-ai-provider/issues) - Include + error messages, code snippets (redact credentials) +4. **SAP Support:** For SAP AI Core service issues - + [SAP AI Core Docs](https://help.sap.com/docs/ai-core) + +## Known Limitations + +This section documents known limitations of the SAP AI Provider that are either +inherent to the underlying SAP AI SDK or are planned for future resolution. + +### Streaming Response ID Is Client-Generated + +**Symptom:** The `response-metadata.id` in streaming responses is a +client-generated UUID, not the server's `x-request-id`. + +**Technical Details:** + +The SAP AI SDK's `OrchestrationStreamResponse` does not currently expose the raw +HTTP response headers, including `x-request-id`. The provider generates a +client-side UUID for response tracing. + +```typescript +// In streaming responses: +for await (const part of stream) { + if (part.type === "response-metadata") { + console.log(part.id); // Client-generated UUID (e.g., "550e8400-e29b-41d4-a716-446655440000") + // Note: This is NOT the server's x-request-id + } +} +``` + +**Impact:** + +- Cannot correlate streaming responses with SAP AI Core server logs using + `x-request-id` +- Non-streaming (`doGenerate`) responses correctly expose `x-request-id` in + `providerMetadata['sap-ai'].requestId` + +**Status:** Waiting for SAP AI SDK enhancement. See +[SAP AI SDK Issue #1433](https://github.com/SAP/ai-sdk-js/issues/1433). + +**Workaround:** For debugging, use non-streaming requests when server-side +request correlation is required, or log the client-generated UUID for +client-side tracing. + +## Related Documentation + +- [README](./README.md) - Getting started +- [API Reference](./API_REFERENCE.md) - Complete API reference +- [Environment Setup](./ENVIRONMENT_SETUP.md) - Authentication setup +- [Architecture](./ARCHITECTURE.md) - Internal architecture +- [cURL API Testing Guide](./CURL_API_TESTING_GUIDE.md) - Direct API testing diff --git a/eslint.config.mjs b/eslint.config.js similarity index 67% rename from eslint.config.mjs rename to eslint.config.js index 237c1d1..bc7b9c4 100644 --- a/eslint.config.mjs +++ b/eslint.config.js @@ -1,15 +1,19 @@ // @ts-check import eslint from "@eslint/js"; +import jsdoc from "eslint-plugin-jsdoc"; +import perfectionist from "eslint-plugin-perfectionist"; import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; export default defineConfig( { - ignores: ["dist/**", "node_modules/**"], + ignores: ["dist/**", "node_modules/**", "coverage/**"], }, eslint.configs.recommended, + jsdoc.configs["flat/recommended-typescript"], tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked, + perfectionist.configs["recommended-natural"], { languageOptions: { parserOptions: { @@ -19,7 +23,7 @@ export default defineConfig( }, }, { - files: ["*.config.{js,mjs}"], extends: [tseslint.configs.disableTypeChecked], + files: ["*.config.{js,mjs}"], }, ); diff --git a/examples/example-chat-completion-tool.ts b/examples/example-chat-completion-tool.ts index d8d5012..7a6ae17 100644 --- a/examples/example-chat-completion-tool.ts +++ b/examples/example-chat-completion-tool.ts @@ -6,7 +6,7 @@ * This example demonstrates tool/function calling with the SAP AI Provider * using the Vercel AI SDK's generateText function with tools. * - * Due to AI SDK v5's Zod schema conversion issues, we define tool schemas + * Due to AI SDK v6's Zod schema conversion issues, we define tool schemas * directly in SAP AI SDK format via provider settings. * * Authentication: @@ -14,64 +14,75 @@ * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON */ -import { generateText, tool, stepCountIs } from "ai"; +// Load environment variables +import "dotenv/config"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +import { generateText, stepCountIs, tool } from "ai"; import { z } from "zod"; + +import type { ChatCompletionTool } from "../src/index"; + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: import { createSAPAIProvider } from "../src/index"; -import type { ChatCompletionTool } from "@sap-ai-sdk/orchestration"; -import "dotenv/config"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider, ChatCompletionTool } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ // Define tool schemas in SAP AI SDK format (proper JSON Schema) // These are passed via provider settings to bypass AI SDK conversion issues const calculatorToolDef: ChatCompletionTool = { - type: "function", function: { - name: "calculate", description: "Perform basic arithmetic operations", + name: "calculate", parameters: { - type: "object", properties: { - operation: { - type: "string", - enum: ["add", "subtract", "multiply", "divide"], - description: "The arithmetic operation to perform", - }, a: { - type: "number", description: "First operand", + type: "number", }, b: { - type: "number", description: "Second operand", + type: "number", + }, + operation: { + description: "The arithmetic operation to perform", + enum: ["add", "subtract", "multiply", "divide"], + type: "string", }, }, required: ["operation", "a", "b"], + type: "object", }, }, + type: "function", }; const weatherToolDef: ChatCompletionTool = { - type: "function", function: { - name: "getWeather", description: "Get weather for a location", + name: "getWeather", parameters: { - type: "object", properties: { location: { - type: "string", description: "The city or location to get weather for", + type: "string", }, }, required: ["location"], + type: "object", }, }, + type: "function", }; // Define Zod schemas for type-safe execute functions const calculatorSchema = z.object({ - operation: z.enum(["add", "subtract", "multiply", "divide"]), a: z.number(), b: z.number(), + operation: z.enum(["add", "subtract", "multiply", "divide"]), }); const weatherSchema = z.object({ @@ -82,102 +93,127 @@ const weatherSchema = z.object({ // The schema here is for validation, actual schema is passed via settings const calculatorTool = tool({ description: "Perform basic arithmetic operations", - inputSchema: calculatorSchema, execute: (args: z.infer) => { - const { operation, a, b } = args; + const { a, b, operation } = args; switch (operation) { case "add": return String(a + b); - case "subtract": - return String(a - b); - case "multiply": - return String(a * b); case "divide": return b !== 0 ? String(a / b) : "Error: Division by zero"; + case "multiply": + return String(a * b); + case "subtract": + return String(a - b); default: return "Unknown operation"; } }, + inputSchema: calculatorSchema, }); const weatherTool = tool({ description: "Get weather for a location", - inputSchema: weatherSchema, execute: (args: z.infer) => { const { location } = args; return `Weather in ${location}: sunny, 72°F`; }, + inputSchema: weatherSchema, }); +/** + * + */ async function simpleToolExample() { console.log("🛠️ SAP AI Tool Calling Example\n"); // Verify AICORE_SERVICE_KEY is set for local development if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { - console.warn( - "⚠️ Warning: AICORE_SERVICE_KEY environment variable not set.", - ); - console.warn( - " Set it in your .env file or environment for local development.\n", - ); + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); } const provider = createSAPAIProvider(); - // Create models with tools defined in settings (proper JSON Schema) - // This bypasses AI SDK's Zod conversion issues - const modelWithCalculator = provider("gpt-4o", { - tools: [calculatorToolDef], - }); - - const modelWithWeather = provider("gpt-4o", { - tools: [weatherToolDef], - }); - - const modelWithAllTools = provider("gpt-4o", { - tools: [calculatorToolDef, weatherToolDef], - }); - - // Test 1: Calculator - console.log("📱 Calculator Test"); - const result1 = await generateText({ - model: modelWithCalculator, - prompt: "What is 15 + 27?", - tools: { - calculate: calculatorTool, - }, - stopWhen: [stepCountIs(5)], - }); - console.log("Answer:", result1.text); - console.log(""); - - // Test 2: Weather - console.log("🌤️ Weather Test"); - const result2 = await generateText({ - model: modelWithWeather, - prompt: "What's the weather in Tokyo?", - tools: { - getWeather: weatherTool, - }, - stopWhen: [stepCountIs(5)], - }); - console.log("Answer:", result2.text); - console.log(""); - - // Test 3: Multiple tools - console.log("🔧 Multiple Tools Test"); - const result3 = await generateText({ - model: modelWithAllTools, - prompt: "Calculate 8 * 7, then tell me about the weather in Paris", - tools: { - calculate: calculatorTool, - getWeather: weatherTool, - }, - stopWhen: [stepCountIs(10)], - }); - console.log("Answer:", result3.text); + try { + // Create models with tools defined in settings (proper JSON Schema) + // This bypasses AI SDK's Zod conversion issues + const modelWithCalculator = provider("gpt-4o", { + tools: [calculatorToolDef], + }); + + const modelWithWeather = provider("gpt-4o", { + tools: [weatherToolDef], + }); + + const modelWithAllTools = provider("gpt-4o", { + tools: [calculatorToolDef, weatherToolDef], + }); + + // Test 1: Calculator + console.log("📱 Calculator Test"); + const result1 = await generateText({ + model: modelWithCalculator, + prompt: "What is 15 + 27?", + stopWhen: [stepCountIs(5)], + tools: { + calculate: calculatorTool, + }, + }); + console.log("Answer:", result1.text); + console.log(""); + + // Test 2: Weather + console.log("🌤️ Weather Test"); + const result2 = await generateText({ + model: modelWithWeather, + prompt: "What's the weather in Tokyo?", + stopWhen: [stepCountIs(5)], + tools: { + getWeather: weatherTool, + }, + }); + console.log("Answer:", result2.text); + console.log(""); + + // Test 3: Multiple tools + console.log("🔧 Multiple Tools Test"); + const result3 = await generateText({ + model: modelWithAllTools, + prompt: "Calculate 8 * 7, then tell me about the weather in Paris", + stopWhen: [stepCountIs(10)], + tools: { + calculate: calculatorTool, + getWeather: weatherTool, + }, + }); + console.log("Answer:", result3.text); + + console.log("\n✅ All tests completed!"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } - console.log("\n✅ All tests completed!"); + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify the model supports tool calling"); + } } simpleToolExample().catch(console.error); diff --git a/examples/example-data-masking.ts b/examples/example-data-masking.ts index 438c9fc..5e8625f 100644 --- a/examples/example-data-masking.ts +++ b/examples/example-data-masking.ts @@ -11,118 +11,158 @@ * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON */ -import { generateText } from "ai"; -import { createSAPAIProvider, buildDpiMaskingProvider } from "../src/index"; +// Load environment variables import "dotenv/config"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +import { generateText } from "ai"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider, buildDpiMaskingProvider } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: +import { buildDpiMaskingProvider, createSAPAIProvider } from "../src/index"; -await (async () => { +/** + * + */ +async function dataMaskingExample() { console.log("🔒 SAP AI Data Masking Example (DPI)\n"); // Verify AICORE_SERVICE_KEY is set for local development if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { - console.warn( - "⚠️ Warning: AICORE_SERVICE_KEY environment variable not set.", - ); - console.warn( - " Set it in your .env file or environment for local development.\n", - ); + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); } - // Build DPI masking configuration using the SDK helper - const dpiMaskingConfig = buildDpiMaskingProvider({ - method: "anonymization", - entities: [ - // Standard entities - "profile-email", - "profile-person", - // Custom entity with replacement strategy - { - type: "profile-phone", - replacement_strategy: { method: "constant", value: "PHONE_REDACTED" }, - }, - ], - }); - - // Provider with masking enabled by default - const provider = createSAPAIProvider({ - defaultSettings: { - masking: { - masking_providers: [dpiMaskingConfig], - }, - }, - }); - - const model = provider("gpt-4o"); - - console.log("📝 Testing with data masking enabled...\n"); - - const { text } = await generateText({ - model, - messages: [ - { - role: "user", - content: - "Please email Jane Doe (jane.doe@example.com) at +1-555-123-4567 about the meeting.", - }, - ], - }); - - console.log("🤖 Response:", text); - console.log( - "\n📌 Note: Personal data like names, emails, and phone numbers should be", - ); - console.log(" masked by DPI before reaching the model."); - - // Test without masking for comparison - console.log("\n================================"); - console.log("🧪 Same prompt WITHOUT data masking (for comparison)"); - console.log("================================\n"); - - const providerNoMask = createSAPAIProvider(); - const modelNoMask = providerNoMask("gpt-4o"); - - const { text: textNoMask } = await generateText({ - model: modelNoMask, - messages: [ - { - role: "user", - content: - "Please email Jane Doe (jane.doe@example.com) at +1-555-123-4567 about the meeting.", + try { + // Build DPI masking configuration using the SDK helper + const dpiMaskingConfig = buildDpiMaskingProvider({ + entities: [ + // Standard entities + "profile-email", + "profile-person", + // Custom entity with replacement strategy + { + replacement_strategy: { + method: "constant", + value: "PHONE_REDACTED", + }, + type: "profile-phone", + }, + ], + method: "anonymization", + }); + + // Provider with masking enabled by default + const provider = createSAPAIProvider({ + defaultSettings: { + masking: { + masking_providers: [dpiMaskingConfig], + }, }, - ], - }); - - console.log("🤖 Response (no masking):", textNoMask); - - // Verbatim echo test - console.log("\n================================"); - console.log("📎 Verbatim echo test (shows what model receives)"); - console.log("================================\n"); - - const original = - "My name is John Smith, email: john.smith@company.com, phone: 555-987-6543"; + }); + + const model = provider("gpt-4o"); + + console.log("📝 Testing with data masking enabled...\n"); + + const { text } = await generateText({ + messages: [ + { + content: + "Please email Jane Doe (jane.doe@example.com) at +1-555-123-4567 about the meeting.", + role: "user", + }, + ], + model, + }); + + console.log("🤖 Response:", text); + console.log("\n📌 Note: Personal data like names, emails, and phone numbers should be"); + console.log(" masked by DPI before reaching the model."); + + // Test without masking for comparison + console.log("\n================================"); + console.log("🧪 Same prompt WITHOUT data masking (for comparison)"); + console.log("================================\n"); + + const providerNoMask = createSAPAIProvider(); + const modelNoMask = providerNoMask("gpt-4o"); + + const { text: textNoMask } = await generateText({ + messages: [ + { + content: + "Please email Jane Doe (jane.doe@example.com) at +1-555-123-4567 about the meeting.", + role: "user", + }, + ], + model: modelNoMask, + }); + + console.log("🤖 Response (no masking):", textNoMask); + + // Verbatim echo test + console.log("\n================================"); + console.log("📎 Verbatim echo test (shows what model receives)"); + console.log("================================\n"); + + const original = "My name is John Smith, email: john.smith@company.com, phone: 555-987-6543"; + + const { text: echoMasked } = await generateText({ + messages: [ + { + content: `Repeat this exactly: ${original}`, + role: "user", + }, + ], + model, + }); + console.log("🔒 Echo with masking:", echoMasked); + + const { text: echoNoMask } = await generateText({ + messages: [ + { + content: `Repeat this exactly: ${original}`, + role: "user", + }, + ], + model: modelNoMask, + }); + console.log("🔓 Echo without masking:", echoNoMask); + + console.log("\n✅ Data masking example completed!"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } + + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify the model is available in your deployment"); + } +} - const { text: echoMasked } = await generateText({ - model, - messages: [ - { - role: "user", - content: `Repeat this exactly: ${original}`, - }, - ], - }); - console.log("🔒 Echo with masking:", echoMasked); - - const { text: echoNoMask } = await generateText({ - model: modelNoMask, - messages: [ - { - role: "user", - content: `Repeat this exactly: ${original}`, - }, - ], - }); - console.log("🔓 Echo without masking:", echoNoMask); +dataMaskingExample().catch(console.error); - console.log("\n✅ Data masking example completed!"); -})(); +export { dataMaskingExample }; diff --git a/examples/example-document-grounding.ts b/examples/example-document-grounding.ts new file mode 100644 index 0000000..d629388 --- /dev/null +++ b/examples/example-document-grounding.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env node + +/** + * SAP AI Provider - Document Grounding (RAG) Example + * + * This example demonstrates document grounding (Retrieval-Augmented Generation) + * using the SAP AI Core Orchestration service's document grounding module. + * + * Document grounding allows you to ground LLM responses in your own documents + * stored in a vector database, ensuring answers are based on your specific + * knowledge base rather than the model's general training data. + * + * Prerequisites: + * - A configured vector database in SAP AI Core (e.g., HANA Cloud Vector Engine) + * - Documents indexed in the vector database + * - Vector store ID (data repository ID) + * + * Authentication: + * - On SAP BTP: Automatically uses service binding (VCAP_SERVICES) + * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON + */ + +// Load environment variables +import "dotenv/config"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +import { generateText } from "ai"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider, buildDocumentGroundingConfig } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: +import { buildDocumentGroundingConfig, createSAPAIProvider } from "../src/index"; + +/** + * + */ +async function documentGroundingExample() { + console.log("📚 SAP AI Document Grounding (RAG) Example\n"); + + // Verify AICORE_SERVICE_KEY is set for local development + if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); + } + + // Check for vector store configuration + const VECTOR_STORE_ID = process.env.VECTOR_STORE_ID ?? "vector-store-1"; + + console.log("📋 Configuration:"); + console.log(` Vector Store ID: ${VECTOR_STORE_ID}`); + console.log(""); + + try { + // Example 1: Basic document grounding configuration + console.log("================================"); + console.log("📖 Example 1: Basic Document Grounding"); + console.log("================================\n"); + + const basicGroundingConfig = buildDocumentGroundingConfig({ + filters: [ + { + // Search across all repositories + data_repositories: ["*"], + id: VECTOR_STORE_ID, + }, + ], + // Required: Define placeholders for input question and output + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }); + + const provider = createSAPAIProvider({ + defaultSettings: { + grounding: basicGroundingConfig, + }, + }); + + const model = provider("gpt-4o"); + + console.log("📝 Query: What are the key features of SAP AI Core?\n"); + + const { text } = await generateText({ + messages: [ + { + content: "What are the key features of SAP AI Core?", + role: "user", + }, + ], + model, + }); + + console.log("🤖 Grounded Response:", text); + console.log("\n📌 Note: This response is grounded in your vector database documents."); + + // Example 2: Advanced grounding with metadata + console.log("\n================================"); + console.log("📊 Example 2: Grounding with Metadata"); + console.log("================================\n"); + + const advancedGroundingConfig = buildDocumentGroundingConfig({ + filters: [ + { + data_repositories: ["*"], + id: VECTOR_STORE_ID, + }, + ], + // Request metadata about the retrieved chunks + metadata_params: ["file_name", "document_id", "chunk_id"], + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }); + + const providerAdvanced = createSAPAIProvider({ + defaultSettings: { + grounding: advancedGroundingConfig, + }, + }); + + const modelAdvanced = providerAdvanced("gpt-4o"); + + console.log("📝 Query: How do I deploy a model in SAP AI Core? Include sources.\n"); + + const { text: advancedText } = await generateText({ + messages: [ + { + content: + "How do I deploy a model in SAP AI Core? Please cite your sources with file names.", + role: "user", + }, + ], + model: modelAdvanced, + }); + + console.log("🤖 Grounded Response with Metadata:", advancedText); + + // Example 3: Comparison with and without grounding + console.log("\n================================"); + console.log("🔍 Example 3: Grounded vs Ungrounded Comparison"); + console.log("================================\n"); + + const providerNoGrounding = createSAPAIProvider(); + const modelNoGrounding = providerNoGrounding("gpt-4o"); + + const query = "What is the latest pricing for our enterprise plan?"; + console.log(`📝 Query: ${query}\n`); + + console.log("🌐 Response WITHOUT grounding (general knowledge):"); + const { text: ungroundedText } = await generateText({ + messages: [ + { + content: query, + role: "user", + }, + ], + model: modelNoGrounding, + }); + console.log(ungroundedText); + + console.log("\n📚 Response WITH grounding (your documents):"); + const { text: groundedText } = await generateText({ + messages: [ + { + content: query, + role: "user", + }, + ], + model, + }); + console.log(groundedText); + + console.log("\n✅ Document grounding example completed!"); + + console.log("\n💡 Next Steps:"); + console.log(" - Index your documents in SAP HANA Cloud Vector Engine"); + console.log(" - Set VECTOR_STORE_ID environment variable"); + console.log(" - Use document_metadata filters to restrict search to specific documents"); + console.log(" - Use metadata_params to retrieve source information for citations"); + console.log(" - Use metadata_params to retrieve source information for citations"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; message?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + console.error(" SAP Error Message:", sapError.error.message); + } + + // Common errors + if (error.statusCode === 400) { + console.error("\n💡 Vector store not found or not configured correctly."); + console.error(" Make sure your vector database is set up in SAP AI Core."); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } + + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify your vector database is configured and populated"); + console.error(" - Ensure VECTOR_STORE_ID matches your actual vector store"); + console.error(" - Check that documents are indexed in the vector database"); + } +} + +documentGroundingExample().catch(console.error); + +export { documentGroundingExample }; diff --git a/examples/example-embeddings.ts b/examples/example-embeddings.ts new file mode 100644 index 0000000..2c075ef --- /dev/null +++ b/examples/example-embeddings.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +/** + * SAP AI Provider - Embeddings Example + * + * This example demonstrates text embedding generation using the SAP AI Provider + * with the Vercel AI SDK's embed and embedMany functions. + * + * Use cases: + * - RAG (Retrieval-Augmented Generation) + * - Semantic search + * - Document similarity + * - Clustering + * + * Authentication: + * - On SAP BTP: Automatically uses service binding (VCAP_SERVICES) + * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON + */ + +// Load environment variables +import "dotenv/config"; +import { APICallError, LoadAPIKeyError } from "@ai-sdk/provider"; +import { embed, embedMany } from "ai"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: +import { createSAPAIProvider } from "../src/index"; + +/** + * Demonstrates single and batch embedding generation + */ +async function embeddingsExample() { + console.log("📊 SAP AI Embeddings Example\n"); + + // Verify AICORE_SERVICE_KEY is set for local development + if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); + } + + try { + const provider = createSAPAIProvider(); + + // ======================================== + // Single Embedding + // ======================================== + console.log("🔢 Generating single embedding...\n"); + + const { embedding } = await embed({ + model: provider.embedding("text-embedding-ada-002"), + value: "What is machine learning and how does it work?", + }); + + console.log("✅ Single embedding generated:"); + console.log(` Dimensions: ${String(embedding.length)}`); + console.log( + ` First 5 values: [${embedding + .slice(0, 5) + .map((v) => v.toFixed(6)) + .join(", ")}...]`, + ); + + // ======================================== + // Batch Embeddings + // ======================================== + console.log("\n🔢 Generating batch embeddings...\n"); + + const documents = [ + "Machine learning is a subset of artificial intelligence.", + "Deep learning uses neural networks with many layers.", + "Natural language processing helps computers understand text.", + "Computer vision enables machines to interpret images.", + ]; + + const { embeddings } = await embedMany({ + model: provider.embedding("text-embedding-ada-002"), + values: documents, + }); + + console.log(`✅ Generated ${String(embeddings.length)} embeddings:`); + embeddings.forEach((emb, idx) => { + const doc = documents[idx] ?? ""; + console.log( + ` [${String(idx)}] "${doc.slice(0, 40)}..." → ${String(emb.length)} dimensions`, + ); + }); + + // ======================================== + // Similarity Calculation (Demo) + // ======================================== + console.log("\n📐 Calculating cosine similarities...\n"); + + // Calculate cosine similarity between embeddings + const cosineSimilarity = (a: number[], b: number[]): number => { + const dotProduct = a.reduce((sum, val, i) => sum + val * (b[i] ?? 0), 0); + const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0)); + const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0)); + return dotProduct / (magnitudeA * magnitudeB); + }; + + // Compare each document to the first one + const referenceDoc = documents[0] ?? ""; + const referenceEmb = embeddings[0] ?? []; + console.log(` Reference: "${referenceDoc.slice(0, 40)}..."`); + for (let i = 1; i < embeddings.length; i++) { + const currentEmb = embeddings[i] ?? []; + const currentDoc = documents[i] ?? ""; + const similarity = cosineSimilarity(referenceEmb, currentEmb); + console.log(` → "${currentDoc.slice(0, 35)}..." similarity: ${similarity.toFixed(4)}`); + } + + // ======================================== + // Different Embedding Types + // ======================================== + console.log("\n🏷️ Testing embedding types...\n"); + + // Document embedding (for storage/indexing) + const { embedding: docEmbedding } = await embed({ + model: provider.embedding("text-embedding-ada-002", { + type: "document", + }), + value: "This is a document to be indexed for later retrieval.", + }); + console.log(` Document embedding: ${String(docEmbedding.length)} dimensions`); + + // Query embedding (for search queries) + const { embedding: queryEmbedding } = await embed({ + model: provider.embedding("text-embedding-ada-002", { + type: "query", + }), + value: "How do I retrieve documents?", + }); + console.log(` Query embedding: ${String(queryEmbedding.length)} dimensions`); + + console.log("\n✅ All embedding tests completed!"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } + + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify the embedding model is available in your deployment"); + } +} + +embeddingsExample().catch(console.error); + +export { embeddingsExample }; diff --git a/examples/example-generate-text.ts b/examples/example-generate-text.ts index 1ca862b..601b524 100644 --- a/examples/example-generate-text.ts +++ b/examples/example-generate-text.ts @@ -11,79 +11,118 @@ * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON */ +// Load environment variables +import "dotenv/config"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; import { generateText } from "ai"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: import { createSAPAIProvider } from "../src/index"; -import "dotenv/config"; -await (async () => { +/** + * + */ +async function generateTextExample() { console.log("📝 SAP AI Text Generation Example\n"); // Verify AICORE_SERVICE_KEY is set for local development if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { - console.warn( - "⚠️ Warning: AICORE_SERVICE_KEY environment variable not set.", - ); - console.warn( - " Set it in your .env file or environment for local development.\n", - ); + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); } - const provider = createSAPAIProvider(); - - // Generate text with GPT-4o - console.log("🤖 Testing gpt-4o..."); - const { text, usage, finishReason } = await generateText({ - model: provider("gpt-4o"), - messages: [ - { - role: "user", - content: "How to make a delicious mashed potatoes?", - }, - ], - }); - - console.log("📄 Response:", text); - console.log( - "📊 Usage:", - `${String(usage.inputTokens)} input + ${String(usage.outputTokens)} output = ${String(usage.totalTokens)} total tokens`, - ); - console.log("🏁 Finish reason:", finishReason); - - // Test multiple models (Harmonized API) - console.log("\n================================"); - console.log("Testing Multiple Models (Harmonized API)"); - console.log("================================\n"); - - const models = ["gemini-2.0-flash", "anthropic--claude-3.5-sonnet"]; - - for (const modelId of models) { - console.log(`\n🤖 Testing ${modelId}...`); - try { - const { - text: modelText, - usage: modelUsage, - finishReason: modelFinish, - } = await generateText({ - model: provider(modelId), - messages: [ - { - role: "user", - content: "What is 2 + 2? Reply with just the number.", - }, - ], - }); - console.log("📄 Response:", modelText); - console.log( - "📊 Usage:", - `${String(modelUsage.inputTokens)} input + ${String(modelUsage.outputTokens)} output`, - ); - console.log("🏁 Finish reason:", modelFinish); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.log(`❌ Error with ${modelId}:`, errorMessage); + try { + const provider = createSAPAIProvider(); + + // Generate text with GPT-4o + console.log("🤖 Testing gpt-4o..."); + const { finishReason, text, usage } = await generateText({ + messages: [ + { + content: "How to make a delicious mashed potatoes?", + role: "user", + }, + ], + model: provider("gpt-4o"), + }); + + console.log("📄 Response:", text); + console.log( + "📊 Usage:", + `${String(usage.inputTokens)} input + ${String(usage.outputTokens)} output = ${String(usage.totalTokens)} total tokens`, + ); + console.log("🏁 Finish reason:", finishReason); + + // Test multiple models (Harmonized API) + console.log("\n================================"); + console.log("Testing Multiple Models (Harmonized API)"); + console.log("================================\n"); + + const models = ["gemini-2.0-flash", "anthropic--claude-3.5-sonnet"]; + + for (const modelId of models) { + console.log(`\n🤖 Testing ${modelId}...`); + try { + const { + finishReason: modelFinish, + text: modelText, + usage: modelUsage, + } = await generateText({ + messages: [ + { + content: "What is 2 + 2? Reply with just the number.", + role: "user", + }, + ], + model: provider(modelId), + }); + console.log("📄 Response:", modelText); + console.log( + "📊 Usage:", + `${String(modelUsage.inputTokens)} input + ${String(modelUsage.outputTokens)} output`, + ); + console.log("🏁 Finish reason:", modelFinish); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`❌ Error with ${modelId}:`, errorMessage); + } } + + console.log("\n✅ All tests completed!"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } + + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify the model is available in your deployment"); } +} + +generateTextExample().catch(console.error); - console.log("\n✅ All tests completed!"); -})(); +export { generateTextExample }; diff --git a/examples/example-image-recognition.ts b/examples/example-image-recognition.ts index 59a9d9f..0cf4845 100644 --- a/examples/example-image-recognition.ts +++ b/examples/example-image-recognition.ts @@ -11,114 +11,154 @@ * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON */ +// Load environment variables +import "dotenv/config"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; import { generateText } from "ai"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: import { createSAPAIProvider } from "../src/index"; -import "dotenv/config"; -await (async () => { +/** + * + */ +async function imageRecognitionExample() { console.log("🖼️ SAP AI Image Recognition Example\n"); // Verify AICORE_SERVICE_KEY is set for local development if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { - console.warn( - "⚠️ Warning: AICORE_SERVICE_KEY environment variable not set.", - ); - console.warn( - " Set it in your .env file or environment for local development.\n", - ); + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); + } + + try { + const provider = createSAPAIProvider(); + + // Example 1: Using a public URL + console.log("📸 Example 1: Public URL Image"); + console.log("=============================="); + + const { text: urlResponse } = await generateText({ + messages: [ + { + content: [ + { + text: "What do you see in this image?", + type: "text", + }, + { + image: new URL( + "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png", + ), + type: "image", + }, + ], + role: "user", + }, + ], + model: provider("gpt-4o"), + }); + + console.log("🤖 Response:", urlResponse); + console.log(""); + + // Example 2: Using base64 encoded image + console.log("📸 Example 2: Base64 Encoded Image"); + console.log("=================================="); + + // Small 1x1 pixel red PNG for demo + const base64Image = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + + const { text: base64Response } = await generateText({ + messages: [ + { + content: [ + { + text: "Describe this image in detail.", + type: "text", + }, + { + image: `data:image/png;base64,${base64Image}`, + type: "image", + }, + ], + role: "user", + }, + ], + model: provider("gpt-4o"), + }); + + console.log("🤖 Response:", base64Response); + console.log(""); + + // Example 3: Multiple images analysis + console.log("📸 Example 3: Multiple Images Analysis"); + console.log("====================================="); + + const { text: multiResponse } = await generateText({ + messages: [ + { + content: [ + { + text: "Compare these two images and tell me what you notice:", + type: "text", + }, + { + image: new URL( + "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png", + ), + type: "image", + }, + { + image: `data:image/png;base64,${base64Image}`, + type: "image", + }, + ], + role: "user", + }, + ], + model: provider("gpt-4o"), + }); + + console.log("🤖 Response:", multiResponse); + console.log(""); + + console.log("✅ All examples completed successfully!"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } + + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify the model is available in your deployment"); } +} + +imageRecognitionExample().catch(console.error); - const provider = createSAPAIProvider(); - - // Example 1: Using a public URL - console.log("📸 Example 1: Public URL Image"); - console.log("=============================="); - - const { text: urlResponse } = await generateText({ - model: provider("gpt-4o"), - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "What do you see in this image?", - }, - { - type: "image", - image: new URL( - "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png", - ), - }, - ], - }, - ], - }); - - console.log("🤖 Response:", urlResponse); - console.log(""); - - // Example 2: Using base64 encoded image - console.log("📸 Example 2: Base64 Encoded Image"); - console.log("=================================="); - - // Small 1x1 pixel red PNG for demo - const base64Image = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; - - const { text: base64Response } = await generateText({ - model: provider("gpt-4o"), - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "Describe this image in detail.", - }, - { - type: "image", - image: `data:image/png;base64,${base64Image}`, - }, - ], - }, - ], - }); - - console.log("🤖 Response:", base64Response); - console.log(""); - - // Example 3: Multiple images analysis - console.log("📸 Example 3: Multiple Images Analysis"); - console.log("====================================="); - - const { text: multiResponse } = await generateText({ - model: provider("gpt-4o"), - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "Compare these two images and tell me what you notice:", - }, - { - type: "image", - image: new URL( - "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png", - ), - }, - { - type: "image", - image: `data:image/png;base64,${base64Image}`, - }, - ], - }, - ], - }); - - console.log("🤖 Response:", multiResponse); - console.log(""); - - console.log("✅ All examples completed successfully!"); -})(); +export { imageRecognitionExample }; diff --git a/examples/example-simple-chat-completion.ts b/examples/example-simple-chat-completion.ts index 368ba79..d81d676 100644 --- a/examples/example-simple-chat-completion.ts +++ b/examples/example-simple-chat-completion.ts @@ -4,29 +4,37 @@ * SAP AI Provider - Simple Chat Completion Example * * This example demonstrates basic chat completion using the SAP AI Provider - * powered by @sap-ai-sdk/orchestration. + * powered by `@sap-ai-sdk/orchestration`. * * Authentication: * - On SAP BTP: Automatically uses service binding (VCAP_SERVICES) * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON */ -// Load environment variables from .env file +// Load environment variables import "dotenv/config"; -import { createSAPAIProvider } from "../src/sap-ai-provider"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: +import { createSAPAIProvider } from "../src/index"; + +/** + * + */ async function simpleTest() { console.log("🧪 Simple SAP AI Chat Completion Example\n"); try { // Verify AICORE_SERVICE_KEY is set for local development if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { - console.warn( - "⚠️ Warning: AICORE_SERVICE_KEY environment variable not set.", - ); - console.warn( - " Set it in your .env file or environment for local development.\n", - ); + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); } console.log("🔄 Creating SAP AI provider..."); @@ -40,18 +48,16 @@ async function simpleTest() { const model = provider("gpt-4o", { modelParams: { - temperature: 0.7, maxTokens: 1000, + temperature: 0.7, }, }); const result = await model.doGenerate({ prompt: [ { + content: [{ text: "How to cook a delicious chicken recipe?", type: "text" }], role: "user", - content: [ - { type: "text", text: "How to cook a delicious chicken recipe?" }, - ], }, ], }); @@ -66,18 +72,35 @@ async function simpleTest() { console.log("📄 Generated text:", text); console.log( "📊 Usage:", - `${String(result.usage.inputTokens)} prompt + ${String(result.usage.outputTokens)} completion tokens`, + `${String(result.usage.inputTokens.total)} prompt + ${String(result.usage.outputTokens.total)} completion tokens`, ); console.log("🏁 Finish reason:", result.finishReason); console.log(""); } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("❌ Test failed:", errorMessage); + if (error instanceof LoadAPIKeyError) { + // 401/403: Authentication or permission issue + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + // 404: Model or deployment not found + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Test failed:", errorMessage); + } console.error("\n💡 Troubleshooting tips:"); - console.error( - " - Ensure AICORE_SERVICE_KEY is set with valid credentials", - ); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); console.error(" - Check that your SAP AI Core instance is accessible"); console.error(" - Verify the model is available in your deployment"); } diff --git a/examples/example-streaming-chat.ts b/examples/example-streaming-chat.ts index 305ac48..c71a919 100644 --- a/examples/example-streaming-chat.ts +++ b/examples/example-streaming-chat.ts @@ -11,23 +11,31 @@ * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON */ -// Load environment variables from .env file +// Load environment variables import "dotenv/config"; -import { createSAPAIProvider } from "../src/sap-ai-provider"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ import { streamText } from "ai"; +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: +import { createSAPAIProvider } from "../src/index"; + +/** + * + */ async function streamingChatExample() { console.log("🧪 Streaming Chat with Vercel AI SDK (streamText)\n"); try { // Verify AICORE_SERVICE_KEY is set for local development if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { - console.warn( - "⚠️ Warning: AICORE_SERVICE_KEY environment variable not set.", - ); - console.warn( - " Set it in your .env file or environment for local development.\n", - ); + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); } console.log("🔄 Creating SAP AI provider..."); @@ -58,13 +66,28 @@ async function streamingChatExample() { `${String(finalUsage.inputTokens)} prompt + ${String(finalUsage.outputTokens)} completion tokens`, ); } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("❌ Streaming example failed:", errorMessage); + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Streaming example failed:", errorMessage); + } console.error("\n💡 Troubleshooting tips:"); - console.error( - " - Ensure AICORE_SERVICE_KEY is set with valid credentials", - ); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); console.error(" - Check that your SAP AI Core instance is accessible"); console.error(" - Verify the model is available in your deployment"); } diff --git a/examples/example-translation.ts b/examples/example-translation.ts new file mode 100644 index 0000000..8032a62 --- /dev/null +++ b/examples/example-translation.ts @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +/** + * SAP AI Provider - Translation Example + * + * This example demonstrates input and output translation using the + * SAP AI Core Orchestration service's translation module. + * + * Translation allows you to: + * - **Input Translation**: Translate user queries from one language to another + * before sending to the LLM (e.g., German → English) + * - **Output Translation**: Translate LLM responses back to the user's language + * (e.g., English → German) + * + * This is particularly useful for: + * - Supporting users in multiple languages with a single English-based LLM + * - Ensuring consistent quality by using the LLM in its strongest language + * - Building multilingual applications without managing separate models + * + * Authentication: + * - On SAP BTP: Automatically uses service binding (VCAP_SERVICES) + * - Locally: Set AICORE_SERVICE_KEY environment variable with your service key JSON + */ + +// Load environment variables +import "dotenv/config"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +import { generateText } from "ai"; +// In YOUR production project, use the published package instead: +// import { createSAPAIProvider, buildTranslationConfig } from "@jerome-benoit/sap-ai-provider"; +// ============================================================================ + +// ============================================================================ +// NOTE: Import Path for Development vs Production +// ============================================================================ +// This example uses relative imports for local development within this repo: +import { buildTranslationConfig, createSAPAIProvider } from "../src/index"; + +/** + * + */ +async function translationExample() { + console.log("🌐 SAP AI Translation Example\n"); + + // Verify AICORE_SERVICE_KEY is set for local development + if (!process.env.AICORE_SERVICE_KEY && !process.env.VCAP_SERVICES) { + console.warn("⚠️ Warning: AICORE_SERVICE_KEY environment variable not set."); + console.warn(" Set it in your .env file or environment for local development.\n"); + } + + try { + // Example 1: Input Translation Only + // User asks in German, LLM processes in English + console.log("================================"); + console.log("📝 Example 1: Input Translation (German → English)"); + console.log("================================\n"); + + const inputTranslationConfig = buildTranslationConfig("input", { + sourceLanguage: "de", // German input + targetLanguage: "en", // Translate to English for LLM + }); + + const providerInputTranslation = createSAPAIProvider({ + defaultSettings: { + translation: { + input: inputTranslationConfig, + }, + }, + }); + + const modelInputTranslation = providerInputTranslation("gpt-4o"); + + const germanQuery = "Was sind die Hauptvorteile von SAP AI Core?"; + console.log(`🇩🇪 German Query: ${germanQuery}\n`); + + const { text: inputTranslatedText } = await generateText({ + messages: [ + { + content: germanQuery, + role: "user", + }, + ], + model: modelInputTranslation, + }); + + console.log("🤖 Response (in English):", inputTranslatedText); + console.log("\n📌 Note: The query was automatically translated from German to English"); + console.log(" before being sent to the LLM."); + + // Example 2: Output Translation Only + // User asks in English, LLM responds in German + console.log("\n================================"); + console.log("📝 Example 2: Output Translation (English → German)"); + console.log("================================\n"); + + const outputTranslationConfig = buildTranslationConfig("output", { + targetLanguage: "de", // Translate response to German + }); + + const providerOutputTranslation = createSAPAIProvider({ + defaultSettings: { + translation: { + output: outputTranslationConfig, + }, + }, + }); + + const modelOutputTranslation = providerOutputTranslation("gpt-4o"); + + const englishQuery = "What are the main benefits of SAP AI Core?"; + console.log(`🇬🇧 English Query: ${englishQuery}\n`); + + const { text: outputTranslatedText } = await generateText({ + messages: [ + { + content: englishQuery, + role: "user", + }, + ], + model: modelOutputTranslation, + }); + + console.log("🤖 Response (in German):", outputTranslatedText); + console.log("\n📌 Note: The LLM processed the query in English and the response"); + console.log(" was automatically translated to German."); + + // Example 3: Bidirectional Translation + // User asks in French, LLM processes in English, responds in French + console.log("\n================================"); + console.log("📝 Example 3: Bidirectional Translation (French ↔ English)"); + console.log("================================\n"); + + const bidirectionalTranslationConfig = { + input: buildTranslationConfig("input", { + sourceLanguage: "fr", + targetLanguage: "en", + }), + output: buildTranslationConfig("output", { + targetLanguage: "fr", + }), + }; + + const providerBidirectional = createSAPAIProvider({ + defaultSettings: { + translation: bidirectionalTranslationConfig, + }, + }); + + const modelBidirectional = providerBidirectional("gpt-4o"); + + const frenchQuery = "Quels sont les principaux avantages de SAP AI Core?"; + console.log(`🇫🇷 French Query: ${frenchQuery}\n`); + + const { text: bidirectionalText } = await generateText({ + messages: [ + { + content: frenchQuery, + role: "user", + }, + ], + model: modelBidirectional, + }); + + console.log("🤖 Response (in French):", bidirectionalText); + console.log("\n📌 Note: The query was translated from French to English,"); + console.log( + " processed by the LLM in English, and the response was translated back to French.", + ); + + // Example 4: Multilingual Support with Different Languages + console.log("\n================================"); + console.log("📝 Example 4: Supporting Multiple Languages"); + console.log("================================\n"); + + const languages = [ + { code: "es", name: "Spanish", query: "¿Qué es SAP AI Core?" }, + { code: "it", name: "Italian", query: "Cos'è SAP AI Core?" }, + { code: "ja", name: "Japanese", query: "SAP AI Coreとは何ですか?" }, + ]; + + for (const lang of languages) { + console.log(`\n${lang.name} (${lang.code}):`); + console.log(` Query: ${lang.query}`); + + const langConfig = { + input: buildTranslationConfig("input", { + sourceLanguage: lang.code, + targetLanguage: "en", + }), + output: buildTranslationConfig("output", { + targetLanguage: lang.code, + }), + }; + + const langProvider = createSAPAIProvider({ + defaultSettings: { + translation: langConfig, + }, + }); + + const langModel = langProvider("gpt-4o"); + + const { text: langText } = await generateText({ + messages: [ + { + content: lang.query, + role: "user", + }, + ], + model: langModel, + }); + + console.log(` Response: ${langText}`); + } + + console.log("\n✅ Translation example completed!"); + + console.log("\n💡 Key Takeaways:"); + console.log(" - Input translation: Translate user queries to LLM's language"); + console.log(" - Output translation: Translate responses to user's language"); + console.log(" - Bidirectional: Combine both for seamless multilingual UX"); + console.log(" - Supported languages: Use ISO 639-1 codes (en, de, fr, es, etc.)"); + console.log(" - No source language needed for output translation (auto-detected)"); + } catch (error: unknown) { + if (error instanceof LoadAPIKeyError) { + console.error("❌ Authentication Error:", error.message); + } else if (error instanceof NoSuchModelError) { + console.error("❌ Model Not Found:", error.modelId); + } else if (error instanceof APICallError) { + console.error("❌ API Call Error:", error.statusCode, error.message); + + // Parse SAP-specific metadata + const sapError = JSON.parse(error.responseBody ?? "{}") as { + error?: { code?: string; message?: string; request_id?: string }; + }; + if (sapError.error?.request_id) { + console.error(" SAP Request ID:", sapError.error.request_id); + console.error(" SAP Error Code:", sapError.error.code); + console.error(" SAP Error Message:", sapError.error.message); + } + + // Common errors + if (error.statusCode === 400) { + console.error("\n💡 Invalid language code or translation configuration."); + console.error(" Use ISO 639-1 language codes (e.g., en, de, fr, es)."); + } + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("❌ Example failed:", errorMessage); + } + + console.error("\n💡 Troubleshooting tips:"); + console.error(" - Ensure AICORE_SERVICE_KEY is set with valid credentials"); + console.error(" - Check that your SAP AI Core instance is accessible"); + console.error(" - Verify the model supports the translation feature"); + console.error(" - Use valid ISO 639-1 language codes (2-letter codes like 'en', 'de')"); + console.error(" - Check SAP AI Core documentation for supported languages"); + } +} + +translationExample().catch(console.error); + +export { translationExample }; diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 0000000..8de8eb8 --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,522 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes + +Create proposal when you need to: + +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): + +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: + +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: + +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** + +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict --no-interactive` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes + +Track these steps as TODOs and complete them one by one. + +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes + +After deployment, create separate PR to: + +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** + +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** + +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding + +### Search Guidance + +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict --no-interactive +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +├── project.md # Project conventions +├── specs/ # Current truth - what IS built +│ └── [capability]/ # Single focused capability +│ ├── spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +├── changes/ # Proposals - what SHOULD change +│ ├── [change-name]/ +│ │ ├── proposal.md # Why, what, impact +│ │ ├── tasks.md # Implementation checklist +│ │ ├── design.md # Technical decisions (optional; see criteria) +│ │ └── specs/ # Delta changes +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +├─ Bug fix restoring spec behavior? → Fix directly +├─ Typo/format/comment? → Fix directly +├─ New feature/capability? → Create proposal +├─ Breaking change? → Create proposal +├─ Architecture change? → Create proposal +└─ Unclear? → Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** + +```markdown +# Change: [Brief description of change] + +## Why + +[1-2 sentences on problem/opportunity] + +## What Changes + +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact + +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` + +```markdown +## ADDED Requirements + +### Requirement: New Feature + +The system SHALL provide... + +#### Scenario: Success case + +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements + +### Requirement: Existing Feature + +[Complete modified requirement] + +## REMOVED Requirements + +### Requirement: Old Feature + +**Reason**: [Why removing] +**Migration**: [How to handle] +``` + +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. + +4. **Create tasks.md:** + +```markdown +## 1. Implementation + +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** + Create `design.md` if any of the following apply; otherwise omit it: + +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: + +```markdown +## Context + +[Background, constraints, stakeholders] + +## Goals / Non-Goals + +- Goals: [...] +- Non-Goals: [...] + +## Decisions + +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs + +- [Risk] → Mitigation + +## Migration Plan + +[Steps, rollback] + +## Open Questions + +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): + +```markdown +#### Scenario: User login success + +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): + +```markdown +- **Scenario: User login** ❌ + **Scenario**: User login ❌ + +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording + +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED + +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: + +1. Locate the existing requirement in `openspec/specs//spec.md`. +2. Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3. Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4. Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: + +```markdown +## RENAMED Requirements + +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** + +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** + +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** + +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict --no-interactive + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict --no-interactive +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md + +```markdown +## ADDED Requirements + +### Requirement: Two-Factor Authentication + +... +``` + +notifications/spec.md + +```markdown +## ADDED Requirements + +### Requirement: OTP Email Notification + +... +``` + +## Best Practices + +### Simplicity First + +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers + +Only add complexity with: + +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References + +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming + +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming + +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +| --------------------- | ---- | ------------------------ | +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts + +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures + +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context + +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators + +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes + +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials + +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict --no-interactive # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000..9e9c4e5 --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,274 @@ +# Project Context + +## Purpose + +This project provides a community-developed provider for SAP AI Core that +integrates seamlessly with the Vercel AI SDK. Built on top of the official +`@sap-ai-sdk/orchestration` package, it enables developers to use SAP's +enterprise-grade AI models (GPT-4, Claude, Gemini, Nova, Llama, etc.) through +the familiar Vercel AI SDK interface. + +**Key Goals:** + +- Simplify SAP AI Core integration with Vercel AI SDK +- Provide type-safe, production-ready AI provider implementation +- Support advanced features: tool calling, streaming, multi-modal inputs, data + masking, content filtering +- Maintain compatibility with both Node.js and edge runtime environments + +## Tech Stack + +**Core Technologies:** + +- **TypeScript** - Primary language (ES2022 target) +- **Node.js** - Runtime (18+ required) +- **ESM** - Module system (with CommonJS output) + +**Build & Testing:** + +- **tsup** - TypeScript bundler (dual ESM/CJS output) +- **vitest** - Unit testing framework with Node.js and Edge runtime configs +- **tsx** - TypeScript execution for examples + +**Dependencies:** + +- **@ai-sdk/provider** (^3.0.4) - Vercel AI SDK provider interfaces +- **@ai-sdk/provider-utils** (^4.0.8) - Vercel AI SDK utilities +- **@sap-ai-sdk/orchestration** (^2.5.0) - Official SAP AI SDK +- **zod** (^4.3.5) - Schema validation + +**Tooling:** + +- **ESLint** - Code linting (TypeScript ESLint) +- **Prettier** - Code formatting +- **dotenv** - Environment variable management + +## Project Conventions + +### Code Style + +**Naming Conventions:** + +- **Files**: kebab-case (e.g., `sap-ai-provider.ts`, + `convert-to-sap-messages.ts`) +- **Classes**: PascalCase (e.g., `SAPAIProvider`, `SAPAILanguageModel`) +- **Functions**: camelCase (e.g., `createSAPAIProvider`, `convertToSAPMessages`) +- **Constants**: UPPER_SNAKE_CASE for true constants, camelCase for config + objects +- **Types/Interfaces**: PascalCase with descriptive names (e.g., + `SAPAIProviderSettings`, `SAPAIModelId`) + +**File Organization:** + +- Co-locate test files: `*.test.ts` alongside implementation files +- All types defined in implementation files or `sap-ai-settings.ts` +- Examples in `examples/` directory with descriptive names + +**Formatting:** + +- Prettier enforced (run `npm run prettier-fix`) +- 2-space indentation +- Double quotes for strings (Prettier default) +- Trailing commas in multi-line structures +- 80-100 character line length (soft limit) + +### Architecture Patterns + +**Provider Pattern:** + +- Factory function `createSAPAIProvider()` returns provider instance +- Default export `sapai` for quick start scenarios +- Provider implements Vercel AI SDK's `LanguageModelV3` and `EmbeddingModelV3` interfaces +- Separation of concerns: provider → model → API client + +**Error Handling:** + +- Use Vercel AI SDK native error types (`APICallError`, `LoadAPIKeyError`, `NoSuchModelError`) +- Automatic retry logic for rate limits (429) and server errors (5xx) +- Detailed error metadata in `responseBody` with SAP-specific fields + +**Configuration:** + +- Environment-based config via `AICORE_SERVICE_KEY` or `VCAP_SERVICES` +- Cascading settings: provider defaults → model overrides → call-specific +- Type-safe configuration with Zod schemas + +**Message Conversion:** + +- Transform Vercel AI SDK messages to SAP AI Core format +- Filter out assistant `reasoning` parts by default (opt-in with + `includeReasoning`) +- Support multi-modal content (text + images) +- Handle tool calls and tool results bidirectionally + +### Testing Strategy + +**Test Framework:** + +- Vitest for all unit and integration tests +- Two test configurations: + - `vitest.node.config.ts` - Node.js runtime tests + - `vitest.edge.config.ts` - Edge runtime compatibility tests + +**Test Organization:** + +- Co-located tests: `*.test.ts` next to implementation +- Test file mirrors source file name +- Group tests by functionality using `describe` blocks + +**Coverage:** + +- Run `npm run test:coverage` for coverage reports +- Aim for high coverage on core provider logic +- Focus on critical paths: message conversion, error handling, API calls + +**Test Commands:** + +```bash +npm test # Run all tests once +npm run test:watch # Watch mode +npm run test:node # Node.js tests only +npm run test:edge # Edge runtime tests only +npm run test:coverage # With coverage report +``` + +**Test Patterns:** + +- Mock SAP AI SDK responses where appropriate +- Test error scenarios explicitly +- Validate type safety in tests +- Include integration tests with real examples + +### Git Workflow + +**Branch Strategy:** + +- `main` - Production-ready code +- Feature branches: `feature/description` or `add-feature-name` +- Bug fixes: `fix/description` or `fix-issue-name` + +**Commit Conventions:** + +- Use descriptive, imperative commit messages +- Reference issues/PRs when applicable +- Keep commits focused and atomic + +**Pre-publish Checks:** + +```bash +npm run prepublishOnly # Runs before npm publish +# - type-check +# - lint +# - test +# - build +# - check-build +``` + +**Release Process:** + +1. Update version in `package.json` +2. Run `npm run prepublishOnly` to validate +3. Create git tag: `v4.0.0` with appropriate version +4. Push to GitHub with tags +5. Publish to npm: `npm publish` + +## Domain Context + +**SAP AI Core Concepts:** + +- **Resource Group**: Logical grouping of AI deployments (default: 'default') +- **Deployment ID**: Unique identifier for a model deployment +- **Service Key**: JSON credential containing authentication details + (`AICORE_SERVICE_KEY`) +- **Orchestration Service**: SAP's LLM orchestration layer supporting multiple + model providers + +**Model Providers:** + +- OpenAI (GPT-4, GPT-4o, o1, o3) +- Anthropic (Claude 3.5, Claude 4) +- Google (Gemini 2.0, 2.5) +- Amazon (Nova Pro, Nova Lite) +- Open source (Mistral, Llama) + +**SAP-Specific Features:** + +- **DPI (Data Privacy Integration)**: PII masking/anonymization +- **Content Filtering**: Azure Content Safety, Llama Guard +- **Grounding**: Document retrieval integration +- **Translation**: Multi-language support + +**Tool Calling Limitations:** + +- Gemini models: 1 tool per request maximum +- GPT-4o, Claude, Nova: Multi-tool support recommended + +## Important Constraints + +**Runtime Compatibility:** + +- Must work in both Node.js (18+) and Edge runtimes +- No Node.js-specific APIs without fallbacks +- Test both environments explicitly + +**SAP AI Core Limitations:** + +- Model availability varies by tenant, region, subscription +- Rate limits enforced by SAP AI Core (auto-retry on 429) +- Authentication requires valid service key or VCAP binding + +**Vercel AI SDK Integration:** + +- Must implement `LanguageModelV3` and `EmbeddingModelV3` interfaces completely +- Follow Vercel AI SDK conventions for errors, streaming, tools +- Maintain compatibility with AI SDK v5.0+ (v6.0+ recommended) + +**Breaking Changes:** + +- Major version bumps for API changes +- Deprecation warnings before removal +- Migration guides for all breaking changes + +**Security:** + +- Never log or expose `AICORE_SERVICE_KEY` +- Validate all external inputs +- Sanitize error messages to avoid credential leaks + +## External Dependencies + +**Primary Dependencies:** + +- **Vercel AI SDK** (`ai` peer dependency) + - Interface definitions: `@ai-sdk/provider` + - Utilities: `@ai-sdk/provider-utils` + - Documentation: + +- **SAP AI SDK** (`@sap-ai-sdk/orchestration`) + - Official SAP Cloud SDK for AI Core + - Handles authentication, API calls, credential management + - Documentation: + +**SAP AI Core API:** + +- Orchestration endpoint: `https:///v2/lm/deployments` +- Authentication: OAuth 2.0 with client credentials +- Regions: US10, EU10, others (tenant-dependent) + +**Development Services:** + +- **npm Registry**: Package distribution +- **GitHub**: Source control and issue tracking +- **TypeScript Compiler**: Type checking and declaration generation + +**Environment Variables:** + +- `AICORE_SERVICE_KEY` - SAP AI Core authentication (JSON) +- `VCAP_SERVICES` - SAP BTP service binding (alternative to service key) +- Optional: `DEPLOYMENT_ID`, `RESOURCE_GROUP` for overrides + +**Testing Dependencies:** + +- Vitest test runner +- Edge runtime VM for compatibility testing +- Coverage tools (v8) diff --git a/package-lock.json b/package-lock.json index c5857b4..0cb0260 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,50 +1,54 @@ { - "name": "@mymediset/sap-ai-provider", - "version": "2.0.2", + "name": "@jerome-benoit/sap-ai-provider", + "version": "4.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@mymediset/sap-ai-provider", - "version": "2.0.2", + "name": "@jerome-benoit/sap-ai-provider", + "version": "4.2.7", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "^3.0.0", - "@ai-sdk/provider-utils": "^4.0.1", - "@sap-ai-sdk/orchestration": "^2.4.0", - "zod": "^4.2.1", - "zod-to-json-schema": "^3.25.1" + "@ai-sdk/provider": "^3.0.5", + "@ai-sdk/provider-utils": "^4.0.9", + "@sap-ai-sdk/orchestration": "^2.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@edge-runtime/vm": "^5.0.0", "@eslint/js": "^9.39.2", - "@types/node": "^25.0.3", + "@types/node": "^25.0.10", + "@vitest/coverage-v8": "^4.0.18", "dotenv": "^17.2.3", "eslint": "^9.39.2", - "globals": "^16.5.0", - "prettier": "^3.7.4", + "eslint-plugin-jsdoc": "^62.4.1", + "eslint-plugin-perfectionist": "^5.4.0", + "globals": "^17.1.0", + "markdown-link-check": "^3.14.2", + "markdownlint-cli": "^0.47.0", + "prettier": "^3.8.1", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.51.0", - "vitest": "^4.0.16" + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.18" }, "engines": { "node": ">=18" }, "peerDependencies": { - "ai": "^6.0.0" + "ai": "^5.0.0 || ^6.0.0" } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.2.tgz", - "integrity": "sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==", + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.22.tgz", + "integrity": "sha512-NgnlY73JNuooACHqUIz5uMOEWvqR1MMVbb2soGLMozLY1fgwEIF5iJFDAGa5/YArlzw2ATVU7zQu7HkR/FUjgA==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@ai-sdk/provider": "3.0.0", - "@ai-sdk/provider-utils": "4.0.1", - "@vercel/oidc": "3.0.5" + "@ai-sdk/provider": "3.0.5", + "@ai-sdk/provider-utils": "4.0.9", + "@vercel/oidc": "3.1.0" }, "engines": { "node": ">=18" @@ -54,9 +58,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.0.tgz", - "integrity": "sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.5.tgz", + "integrity": "sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -66,12 +70,12 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.1.tgz", - "integrity": "sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.9.tgz", + "integrity": "sha512-bB4r6nfhBOpmoS9mePxjRoCy+LnzP3AfhyMGCkGL4Mn9clVNlqEeKj26zEKEtB6yoSVcT1IQ0Zh9fytwMCDnow==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.0", + "@ai-sdk/provider": "3.0.5", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, @@ -82,6 +86,66 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -118,6 +182,7 @@ "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@edge-runtime/primitives": "6.0.0" }, @@ -125,6 +190,33 @@ "node": ">=18" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.83.0.tgz", + "integrity": "sha512-e1MHSEPJ4m35zkBvNT6kcdeH1SvMaJDsPC3Xhfseg3hvF50FUE3f46Yn36jgbrPYYXezlWUQnevv23c+lx2MCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.53.1", + "comment-parser": "1.4.5", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.1.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -568,9 +660,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -776,6 +868,29 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -815,20 +930,71 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@oozcitak/dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", "cpu": [ "arm" ], @@ -840,9 +1006,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", "cpu": [ "arm64" ], @@ -854,9 +1020,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", "cpu": [ "arm64" ], @@ -868,9 +1034,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", "cpu": [ "x64" ], @@ -882,9 +1048,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", "cpu": [ "arm64" ], @@ -896,9 +1062,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", "cpu": [ "x64" ], @@ -910,9 +1076,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", "cpu": [ "arm" ], @@ -924,9 +1090,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", "cpu": [ "arm" ], @@ -938,9 +1104,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", "cpu": [ "arm64" ], @@ -952,9 +1118,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", "cpu": [ "arm64" ], @@ -966,9 +1132,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", "cpu": [ "loong64" ], @@ -980,9 +1160,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", "cpu": [ "ppc64" ], @@ -994,9 +1188,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", "cpu": [ "riscv64" ], @@ -1008,9 +1202,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", "cpu": [ "riscv64" ], @@ -1022,9 +1216,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", "cpu": [ "s390x" ], @@ -1036,9 +1230,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", "cpu": [ "x64" ], @@ -1050,9 +1244,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", "cpu": [ "x64" ], @@ -1063,10 +1257,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", "cpu": [ "arm64" ], @@ -1078,9 +1286,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", "cpu": [ "arm64" ], @@ -1092,9 +1300,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", "cpu": [ "ia32" ], @@ -1106,9 +1314,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", "cpu": [ "x64" ], @@ -1120,9 +1328,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", "cpu": [ "x64" ], @@ -1134,114 +1342,115 @@ ] }, "node_modules/@sap-ai-sdk/ai-api": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.4.0.tgz", - "integrity": "sha512-eVQb58TLBvolRZ560Y0rYSZZlmAGVDSNPhsb3zPbIuZakGstTAEV0NfvXx5zPUwDSDINUlzgKRdff9yTKvcqwg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.5.0.tgz", + "integrity": "sha512-jhijNSpb7zURSNBgx9vzoPH1GSp9yZRGnwpZ2gAgjK2j5dyx9uJgjBItCkb7U1SlDjby5Ju9T/Cu8AZInVdwfw==", "license": "Apache-2.0", "dependencies": { - "@sap-ai-sdk/core": "^2.4.0", - "@sap-cloud-sdk/connectivity": "^4.2.0", - "@sap-cloud-sdk/util": "^4.2.0" + "@sap-ai-sdk/core": "^2.5.0", + "@sap-cloud-sdk/connectivity": "^4.3.1", + "@sap-cloud-sdk/util": "^4.3.1" } }, "node_modules/@sap-ai-sdk/core": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.4.0.tgz", - "integrity": "sha512-4ft1nRRFiklQBGODCrQNQKs0Hrcr6qOeEMnlCImr2u8mlS104zQb0bQCHlQXIlW81EVoiuVpvStYG/DL5dJ3mw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.5.0.tgz", + "integrity": "sha512-BJQUG+kw9W4moaIB9GQ93teEOAU18Fvbr9BTwfT5gaiD1l9M+HAxirNSH9quTpdGOszbDtg4YbrQB2jh/JesLA==", "license": "Apache-2.0", "dependencies": { - "@sap-cloud-sdk/connectivity": "^4.2.0", - "@sap-cloud-sdk/http-client": "^4.2.0", - "@sap-cloud-sdk/openapi": "^4.2.0", - "@sap-cloud-sdk/util": "^4.2.0" + "@sap-cloud-sdk/connectivity": "^4.3.1", + "@sap-cloud-sdk/http-client": "^4.3.1", + "@sap-cloud-sdk/openapi": "^4.3.1", + "@sap-cloud-sdk/util": "^4.3.1" } }, "node_modules/@sap-ai-sdk/orchestration": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.4.0.tgz", - "integrity": "sha512-+PBoBr/Ccxf3P/j/ZagKBqH384Pq0R/XUgu9jK1dRUk2FjKP3GG+Mh6uRbaly12icx6kC/kHzIbWzuffOTQpmw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.5.0.tgz", + "integrity": "sha512-pztozIP1PXHSqilT1Lqmmn63iZczfdaldNpylLHmvDfSv9aWupBGer7z0uFUhq5Qjzglt5JuUK1L8QevKT8asg==", "license": "Apache-2.0", "dependencies": { - "@sap-ai-sdk/ai-api": "^2.4.0", - "@sap-ai-sdk/core": "^2.4.0", - "@sap-ai-sdk/prompt-registry": "^2.4.0", - "@sap-cloud-sdk/util": "^4.2.0", + "@sap-ai-sdk/ai-api": "^2.5.0", + "@sap-ai-sdk/core": "^2.5.0", + "@sap-ai-sdk/prompt-registry": "^2.5.0", + "@sap-cloud-sdk/util": "^4.3.1", "yaml": "^2.8.2" } }, "node_modules/@sap-ai-sdk/prompt-registry": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.4.0.tgz", - "integrity": "sha512-5qay/ILBcfD4Om/defqipzhWp1uOq5UNTC2/1uHPy14Hc4GeJvoyGh/PE8XG5Ay5bA7+YLxDEjjrUP7iAzTdNw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.5.0.tgz", + "integrity": "sha512-NfQFXjRaUCQpqPlVuEkBuSCg8BP5Mz2ZnGjKwkydv1H7r+PQJu15peqP6JYcL+4Fk1EI9bgaYh5RCCCOjchVwg==", "license": "Apache-2.0", "dependencies": { - "@sap-ai-sdk/core": "^2.4.0", - "zod": "^4.2.1" + "@sap-ai-sdk/core": "^2.5.0", + "zod": "^4.3.5" } }, "node_modules/@sap-cloud-sdk/connectivity": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/connectivity/-/connectivity-4.2.0.tgz", - "integrity": "sha512-dvQpjYWp9EhoUip4ftvnsVOG6BHJO+fOOuVs0R7V1IRyl7F49QPdpI/YoMzk3W7Rex9NozRa/0EXAe9L0CrFbA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/connectivity/-/connectivity-4.3.1.tgz", + "integrity": "sha512-gugKiYtXQHOrvt2qnydGvk3g7xlX6F3lEVBWpPpFGUJPRQpAE/AptM9egnty2zv02a6YIhNcEc//kzBfM9A70Q==", "license": "Apache-2.0", "dependencies": { - "@sap-cloud-sdk/resilience": "^4.2.0", - "@sap-cloud-sdk/util": "^4.2.0", + "@sap-cloud-sdk/resilience": "^4.3.1", + "@sap-cloud-sdk/util": "^4.3.1", "@sap/xsenv": "^6.0.0", - "@sap/xssec": "^4.11.2", + "@sap/xssec": "^4.12.1", "async-retry": "^1.3.3", "axios": "^1.13.2", - "jsonwebtoken": "^9.0.2" + "jks-js": "^1.1.4", + "jsonwebtoken": "^9.0.3" } }, "node_modules/@sap-cloud-sdk/http-client": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/http-client/-/http-client-4.2.0.tgz", - "integrity": "sha512-jpMeQpcyY3wfEbNlo0yqgu6jp+Rr3nOBYPt/k6GdjgR9qQFPgY1m2ZEsyd8B1Dhzb+BS3iW7fHunshVvGjCgtQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/http-client/-/http-client-4.3.1.tgz", + "integrity": "sha512-WnnffWMgvLR7C68T6ZRnSeWXGgPKIiPaPypE4BtijJKueTSk7kVLblu9t5dlH51S5pI9HoDFt9OVqRrsBTnFAQ==", "license": "Apache-2.0", "dependencies": { - "@sap-cloud-sdk/connectivity": "^4.2.0", - "@sap-cloud-sdk/resilience": "^4.2.0", - "@sap-cloud-sdk/util": "^4.2.0", + "@sap-cloud-sdk/connectivity": "^4.3.1", + "@sap-cloud-sdk/resilience": "^4.3.1", + "@sap-cloud-sdk/util": "^4.3.1", "axios": "^1.13.2" } }, "node_modules/@sap-cloud-sdk/openapi": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/openapi/-/openapi-4.2.0.tgz", - "integrity": "sha512-lubhOWJqE6n8MZcBjjd6uMFfI8Im3Jma1Mimsh/lJgJP2seOQ7eXnD0zi1zLC8ah4x+k86QrLozdJ5F5Izgvzw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/openapi/-/openapi-4.3.1.tgz", + "integrity": "sha512-I5zlquFaNKr3gz/AOQ7cYFUi/rgDIkCalmAZUbWM4E0rR3jWuX1ochu650Qy2gAB7FOZ6nQtqPW+0kHDXnR1fg==", "license": "Apache-2.0", "dependencies": { - "@sap-cloud-sdk/connectivity": "^4.2.0", - "@sap-cloud-sdk/http-client": "^4.2.0", - "@sap-cloud-sdk/resilience": "^4.2.0", - "@sap-cloud-sdk/util": "^4.2.0", + "@sap-cloud-sdk/connectivity": "^4.3.1", + "@sap-cloud-sdk/http-client": "^4.3.1", + "@sap-cloud-sdk/resilience": "^4.3.1", + "@sap-cloud-sdk/util": "^4.3.1", "axios": "^1.13.2" } }, "node_modules/@sap-cloud-sdk/resilience": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/resilience/-/resilience-4.2.0.tgz", - "integrity": "sha512-ZR2UugUEKEawIu6SIfz9OM8nQO43s/37jnmU4L+vTQN+ptFdFg+2bJAjj+F3jyqSaBx6JbALs9r6X2eWRc+ZDQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/resilience/-/resilience-4.3.1.tgz", + "integrity": "sha512-lw9tT+9l94bIKvpbN9016ELr+qYANfAgkp8qL0Wsm1OGCPbALWtfNVBPY4Ni4O6ep+6sIFL6ACcwjrdDrogF8A==", "license": "Apache-2.0", "dependencies": { - "@sap-cloud-sdk/util": "^4.2.0", + "@sap-cloud-sdk/util": "^4.3.1", "async-retry": "^1.3.3", "axios": "^1.13.2", "opossum": "^9.0.0" } }, "node_modules/@sap-cloud-sdk/util": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/util/-/util-4.2.0.tgz", - "integrity": "sha512-sAyb1hWATZKKu+NZWX65oIdSI4uKj//0UOl4vtzMPNgYXhF806aAWbj2tLZAHI4UTtQWedbfcHhwO7BLl9MvQQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@sap-cloud-sdk/util/-/util-4.3.1.tgz", + "integrity": "sha512-ew3+RiyUAKKhb/h/M1g2di9DjoeDQ3uQIDS7a+rN9lqlaP2+mb0mbK8x1PHLJ8agvZNjFR5y5k2jKmIiHVFT3A==", "license": "Apache-2.0", "dependencies": { "axios": "^1.13.2", "chalk": "^4.1.0", "logform": "^2.7.0", "voca": "^1.4.1", - "winston": "^3.18.3", + "winston": "^3.19.0", "winston-transport": "^4.9.0" } }, @@ -1260,9 +1469,9 @@ } }, "node_modules/@sap/xssec": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@sap/xssec/-/xssec-4.12.1.tgz", - "integrity": "sha512-LcCnFuoNKosXJ9H71Yd9vRNknB+LHsghro1eHgQ8jUmacSt/C2USL7BsJ4TDb4DxeeZB4wieUEy5HBPZM9T+Jw==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@sap/xssec/-/xssec-4.12.2.tgz", + "integrity": "sha512-T4yy/lXZerAREJnb2Yte3oL4iDJOKlmWrMwMLXY5/U33tuKDVzIVO3VQqfLTGTIJABk4msE+km0YWpds+fSv3w==", "license": "SAP DEVELOPER LICENSE AGREEMENT", "dependencies": { "debug": "^4.4.3", @@ -1289,6 +1498,19 @@ } } }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -1305,6 +1527,13 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1316,6 +1545,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1337,12 +1576,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1353,21 +1607,28 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", - "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", + "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/type-utils": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1377,7 +1638,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.51.0", + "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1393,17 +1654,18 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", - "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", + "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1417,16 +1679,34 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/project-service": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", - "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", + "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.51.0", - "@typescript-eslint/types": "^8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1439,15 +1719,33 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", - "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", + "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0" + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1458,9 +1756,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", - "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", + "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", "dev": true, "license": "MIT", "engines": { @@ -1475,17 +1773,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", - "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", + "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.2.0" + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1499,10 +1797,28 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", - "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", + "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", "dev": true, "license": "MIT", "engines": { @@ -1514,21 +1830,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", - "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", + "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.51.0", - "@typescript-eslint/tsconfig-utils": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1551,6 +1867,24 @@ "balanced-match": "^1.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -1568,16 +1902,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", - "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", + "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1592,13 +1926,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", - "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", + "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/types": "8.53.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1610,27 +1944,57 @@ } }, "node_modules/@vercel/oidc": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz", - "integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 20" } }, - "node_modules/@vitest/expect": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", - "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", - "chai": "^6.2.1", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1638,13 +2002,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", - "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.16", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1665,9 +2029,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", - "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -1678,13 +2042,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", - "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.0.18", "pathe": "^2.0.3" }, "funding": { @@ -1692,13 +2056,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", - "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1707,9 +2071,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", - "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { @@ -1717,13 +2081,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", - "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1736,6 +2100,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1753,16 +2118,26 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ai": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.3.tgz", - "integrity": "sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==", + "version": "6.0.49", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.49.tgz", + "integrity": "sha512-LABniBX/0R6Tv+iUK5keUZhZLaZUe4YjP5M2rZ4wAdZ8iKV3EfTAoJxuL1aaWTSJKIilKa9QUEkCgnp89/32bw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@ai-sdk/gateway": "3.0.2", - "@ai-sdk/provider": "3.0.0", - "@ai-sdk/provider-utils": "4.0.1", + "@ai-sdk/gateway": "3.0.22", + "@ai-sdk/provider": "3.0.5", + "@ai-sdk/provider-utils": "4.0.9", "@opentelemetry/api": "1.9.0" }, "engines": { @@ -1789,6 +2164,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1811,6 +2199,16 @@ "dev": true, "license": "MIT" }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1818,6 +2216,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -1837,6 +2244,31 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1859,9 +2291,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", + "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1876,6 +2308,23 @@ "dev": true, "license": "MIT" }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1968,6 +2417,83 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2079,13 +2605,23 @@ } }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" + } + }, + "node_modules/comment-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", + "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" } }, "node_modules/concat-map": { @@ -2133,6 +2669,46 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2150,6 +2726,30 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2157,6 +2757,21 @@ "dev": true, "license": "MIT" }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2166,6 +2781,89 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -2208,6 +2906,33 @@ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2267,6 +2992,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2315,12 +3041,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2375,25 +3124,120 @@ } } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/eslint-plugin-jsdoc": { + "version": "62.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.4.1.tgz", + "integrity": "sha512-HgX2iN4j104D/mCUqRbhtzSZbph+KO9jfMHiIJjJ19Q+IwLQ5Na2IqvOJYq4S+4kgvEk1w6KYF4vVus6H2wcHg==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "@es-joy/jsdoccomment": "~0.83.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.5", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.1.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.3", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", + "node_modules/eslint-plugin-jsdoc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", + "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.4.0.tgz", + "integrity": "sha512-XxpUMpeVaSJF5rpF6NHmhj3xavHZrflKcRbDssAUWrHUU/+l3l7PPYnVJ6IOpR2KjQ1Blucaeb0cFL3LIBis0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.53.1", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": ">=8.45.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, @@ -2423,10 +3267,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2681,6 +3539,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2718,6 +3589,34 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2732,9 +3631,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.1.0.tgz", + "integrity": "sha512-8HoIcWI5fCvG5NADj4bDav+er9B9JMj2vyL2pI8D0eismKyUvPLTSs+Ln3wqhwcp306i73iyVnEKx3F6T47TGw==", "dev": true, "license": "MIT", "engines": { @@ -2804,6 +3703,114 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-link-extractor": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/html-link-extractor/-/html-link-extractor-1.0.5.tgz", + "integrity": "sha512-ADd49pudM157uWHwHQPUSX4ssMsvR/yHIswOR5CUfBdK9g9ZYGMhVSE6KZVHJ6kCkR0gH4htsfzU6zECDNVwyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio": "^1.0.0-rc.10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2847,6 +3854,76 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2870,6 +3947,33 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-relative-url": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-relative-url/-/is-relative-url-4.1.0.tgz", + "integrity": "sha512-vhIXKasjAuxS7n+sdv7pJQykEAgS+YU8VBQOENXwo/VZpOHDgBBsIbHo7zFKaWBjYWF4qxERdhbPRRtFAeJKfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute-url": "^4.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2889,6 +3993,56 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jks-js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/jks-js/-/jks-js-1.1.5.tgz", + "integrity": "sha512-Kdl/twc+Nk8jPWqH3jCp3YE8jlG4Q7ijbAhhG65chfNnkQxOyXY60xLryz1Fnew8MV64rcXLtIT1PuTW0B15eA==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.2", + "node-int64": "^0.4.0", + "node-rsa": "^1.1.1" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2899,6 +4053,13 @@ "node": ">=10" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2912,7 +4073,17 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-buffer": { + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.0.tgz", + "integrity": "sha512-SX7q7XyCwzM/MEDCYz0l8GgGbJAACGFII9+WfNYr5SLEKukHWRy2Jk3iWRe7P+lpYJNs7oQ+OSei4JtKGUjd7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", @@ -2939,6 +4110,23 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2991,6 +4179,33 @@ "node": ">=18" } }, + "node_modules/katex": { + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3041,6 +4256,30 @@ "dev": true, "license": "MIT" }, + "node_modules/link-check": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/link-check/-/link-check-5.5.1.tgz", + "integrity": "sha512-GrtE4Zp/FBduvElmad375NrPeMYnKwNt9rH/TDG/rbQbHL0QVC4S/cEPVKZ0CkhXlVuiK+/5flGpRxQzoLbjEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-relative-url": "^4.1.0", + "ms": "^2.1.3", + "needle": "^3.3.1", + "node-email-verifier": "^3.4.1", + "proxy-agent": "^6.5.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -3133,6 +4372,16 @@ "node": ">= 12.0.0" } }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3143,218 +4392,1171 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, "engines": { - "node": ">= 0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, - "engines": { - "node": ">= 0.6" + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/markdown-link-check": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/markdown-link-check/-/markdown-link-check-3.14.2.tgz", + "integrity": "sha512-DPJ+itd3D5fcfXD5s1i53lugH0Z/h80kkQxlYCBh8tFwEZGhyVgDcLl0rnKlWssAVDAmSmcbePpHpMEY+JcMMQ==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "async": "^3.2.6", + "chalk": "^5.6.2", + "commander": "^14.0.2", + "link-check": "^5.5.1", + "markdown-link-extractor": "^4.0.3", + "needle": "^3.3.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "xmlbuilder2": "^4.0.0" }, - "engines": { - "node": "*" + "bin": { + "markdown-link-check": "markdown-link-check" } }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "node_modules/markdown-link-check/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/markdown-link-extractor": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/markdown-link-extractor/-/markdown-link-extractor-4.0.3.tgz", + "integrity": "sha512-aEltJiQ4/oC0h6Jbw/uuATGSHZPkcH8DIunNH1A0e+GSFkvZ6BbBkdvBTVfIV8r6HapCU3yTd0eFdi3ZeM1eAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "html-link-extractor": "^1.0.5", + "marked": "^17.0.0" + } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/markdownlint": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz", + "integrity": "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==", "dev": true, "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/markdownlint-cli": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.47.0.tgz", + "integrity": "sha512-HOcxeKFAdDoldvoYDofd85vI8LgNWy8vmYpCwnlLV46PJcodmGzD7COSSBlhHwsfT4o9KrAStGodImVBus31Bg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "dependencies": { + "commander": "~14.0.2", + "deep-extend": "~0.6.0", + "ignore": "~7.0.5", + "js-yaml": "~4.1.1", + "jsonc-parser": "~3.3.1", + "jsonpointer": "~5.0.1", + "markdown-it": "~14.1.0", + "markdownlint": "~0.40.0", + "minimatch": "~10.1.1", + "run-con": "~1.3.2", + "smol-toml": "~1.5.2", + "tinyglobby": "~0.2.15" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "markdownlint": "markdownlint.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=20" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/markdownlint-cli/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT" - }, - "node_modules/node-cache": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", - "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/markdownlint-cli/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "clone": "2.x" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">= 8.0.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", "dev": true, "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 20" } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", "dev": true, "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, "license": "MIT", "dependencies": { - "fn.name": "1.x.x" + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/opossum": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/opossum/-/opossum-9.0.0.tgz", - "integrity": "sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw==", - "license": "Apache-2.0", - "engines": { - "node": "^24 || ^22 || ^20" + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/needle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", + "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/node-email-verifier": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/node-email-verifier/-/node-email-verifier-3.4.1.tgz", + "integrity": "sha512-69JMeWgEUrCji+dOLULirdSoosRxgAq2y+imfmHHBGvgTwyTKqvm65Ls3+W30DCIWMrYj5kKVb/DHTQDK7OVwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3", + "validator": "^13.15.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "license": "MIT", + "dependencies": { + "asn1": "^0.2.4" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-deep-merge": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", + "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/opossum": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/opossum/-/opossum-9.0.0.tgz", + "integrity": "sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw==", + "license": "Apache-2.0", + "engines": { + "node": "^24 || ^22 || ^20" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "entities": "^6.0.0" }, - "engines": { - "node": ">= 0.8.0" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "dev": true, "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" + "domhandler": "^5.0.3", + "parse5": "^7.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" + "parse5": "^7.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">=6" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/path-exists": { @@ -3397,6 +5599,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3446,6 +5649,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3509,9 +5713,9 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -3524,6 +5728,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3540,6 +5774,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3568,6 +5812,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3578,6 +5835,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -3588,9 +5855,9 @@ } }, "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "dev": true, "license": "MIT", "dependencies": { @@ -3604,31 +5871,50 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" } }, + "node_modules/run-con": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.3.2.tgz", + "integrity": "sha512-CcfE+mYiTcKEzg0IqS08+efdnH0oJ3zV0wSUFBNrMHMuxCtXvBCLzCJHatwuXDcu/RlhjTziTo/a1ruQik6/Yg==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~4.1.0", + "minimist": "^1.2.8", + "strip-json-comments": "~3.1.1" + }, + "bin": { + "run-con": "cli.js" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3658,6 +5944,22 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3700,14 +6002,69 @@ "dev": true, "license": "ISC" }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "optional": true, "engines": { - "node": ">= 12" + "node": ">=0.10.0" } }, "node_modules/source-map-js": { @@ -3720,6 +6077,31 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -3752,6 +6134,39 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3788,6 +6203,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3870,6 +6295,23 @@ "node": ">=14.0.0" } }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3890,9 +6332,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", - "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -3909,6 +6351,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -3972,6 +6421,37 @@ "node": ">=8" } }, + "node_modules/tsup/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3991,6 +6471,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4000,16 +6481,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", - "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.1.tgz", + "integrity": "sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.51.0", - "@typescript-eslint/parser": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/utils": "8.51.0" + "@typescript-eslint/eslint-plugin": "8.53.1", + "@typescript-eslint/parser": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4023,13 +6504,30 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz", + "integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -4053,6 +6551,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -4068,11 +6576,12 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4143,19 +6652,20 @@ } }, "node_modules/vitest": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", - "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@vitest/expect": "4.0.16", - "@vitest/mocker": "4.0.16", - "@vitest/pretty-format": "4.0.16", - "@vitest/runner": "4.0.16", - "@vitest/snapshot": "4.0.16", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -4183,10 +6693,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.16", - "@vitest/browser-preview": "4.0.16", - "@vitest/browser-webdriverio": "4.0.16", - "@vitest/ui": "4.0.16", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -4236,6 +6746,30 @@ "integrity": "sha512-NJC/BzESaHT1p4B5k4JykxedeltmNbau4cummStd4RjFojgq/kLew5TzYge9N2geeWyI2w8T30wUET5v+F7ZHA==", "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4315,6 +6849,22 @@ "node": ">=0.10.0" } }, + "node_modules/xmlbuilder2": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" + }, + "engines": { + "node": ">=20.0" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -4344,22 +6894,14 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } } } } diff --git a/package.json b/package.json index 7d8f8cb..666098b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@mymediset/sap-ai-provider", - "version": "2.0.2", + "name": "@jerome-benoit/sap-ai-provider", + "version": "4.2.7", "type": "module", "description": "SAP AI Core provider for AI SDK (powered by @sap-ai-sdk/orchestration)", "keywords": [ @@ -16,16 +16,19 @@ "agent", "orchestration" ], - "homepage": "https://github.com/BITASIA/sap-ai-provider#readme", + "homepage": "https://github.com/jerome-benoit/sap-ai-provider#readme", "bugs": { - "url": "https://github.com/BITASIA/sap-ai-provider/issues" + "url": "https://github.com/jerome-benoit/sap-ai-provider/issues" }, "repository": { "type": "git", - "url": "git+https://github.com/BITASIA/sap-ai-provider.git" + "url": "git+https://github.com/jerome-benoit/sap-ai-provider.git" }, "license": "Apache-2.0", - "author": "mymediset", + "author": "jerome-benoit", + "volta": { + "node": "24.13.0" + }, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,49 +45,58 @@ "files": [ "dist/**/*", "README.md", - "CHANGELOG.md", - "LICENSE" + "LICENSE.md" ], "scripts": { "build": "tsup", "build:watch": "tsup --watch", "clean": "rm -rf dist", - "lint": "eslint .", - "lint-fix": "eslint . --fix", + "lint": "npm run lint:md:all && eslint .", + "lint-fix": "npm run lint:md:fix && eslint . --fix", + "lint:md": "markdownlint -c .markdownlintrc '**/*.md'", + "lint:md:fix": "markdownlint -c .markdownlintrc '**/*.md' --fix", + "lint:md:links": "find . -name '*.md' -not -path './node_modules/*' -exec markdown-link-check -c .markdown-link-check.json {} \\;", + "lint:md:toc": "tsx scripts/check-toc.ts", + "lint:md:all": "npm run lint:md && npm run lint:md:toc", "type-check": "tsc --noEmit", - "prettier-check": "prettier --check \"./**/*.ts\"", - "prettier-fix": "prettier --write \"./**/*.ts\"", + "prettier-check": "prettier --check .", + "prettier-fix": "prettier --write .", "test": "vitest run", "test:watch": "vitest", - "test:node": "vitest --config vitest.node.config.js --run", - "test:edge": "vitest --config vitest.edge.config.js --run", - "test:node:watch": "vitest --config vitest.node.config.js", - "prepare": "npm run build", - "prepublishOnly": "npm run type-check && npm run test && npm run build", + "test:node": "vitest --config vitest.node.config.ts --run", + "test:edge": "vitest --config vitest.edge.config.ts --run", + "test:node:watch": "vitest --config vitest.node.config.ts", + "test:coverage": "vitest run --coverage", + "prepublishOnly": "npm run type-check && npm run lint && npm run test && npm run build && npm run check-build", "check-build": "ls -la dist/ && test -f dist/index.js && test -f dist/index.cjs && test -f dist/index.d.ts" }, "dependencies": { - "@ai-sdk/provider": "^3.0.0", - "@ai-sdk/provider-utils": "^4.0.1", - "@sap-ai-sdk/orchestration": "^2.4.0", - "zod": "^4.2.1", - "zod-to-json-schema": "^3.25.1" + "@ai-sdk/provider": "^3.0.5", + "@ai-sdk/provider-utils": "^4.0.9", + "@sap-ai-sdk/orchestration": "^2.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@edge-runtime/vm": "^5.0.0", "@eslint/js": "^9.39.2", - "@types/node": "^25.0.3", + "@types/node": "^25.0.10", + "@vitest/coverage-v8": "^4.0.18", "dotenv": "^17.2.3", "eslint": "^9.39.2", - "globals": "^16.5.0", - "prettier": "^3.7.4", + "eslint-plugin-jsdoc": "^62.4.1", + "eslint-plugin-perfectionist": "^5.4.0", + "globals": "^17.1.0", + "markdown-link-check": "^3.14.2", + "markdownlint-cli": "^0.47.0", + "prettier": "^3.8.1", "tsup": "^8.5.1", + "tsx": "^4.21.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.51.0", - "vitest": "^4.0.16" + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.18" }, "peerDependencies": { - "ai": "^6.0.0" + "ai": "^5.0.0 || ^6.0.0" }, "engines": { "node": ">=18" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..da29153 --- /dev/null +++ b/renovate.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "abandonments:recommended", + ":configMigration", + "group:allNonMajor", + "schedule:daily", + ":maintainLockFilesWeekly" + ], + "packageRules": [ + { + "matchFileNames": ["**/package.json"], + "matchDepTypes": ["dependencies", "devDependencies", "optionalDependencies"], + "rangeStrategy": "bump" + } + ] +} diff --git a/scripts/check-toc.test.ts b/scripts/check-toc.test.ts new file mode 100644 index 0000000..3280f87 --- /dev/null +++ b/scripts/check-toc.test.ts @@ -0,0 +1,815 @@ +/** + * Unit tests for ToC (Table of Contents) Validation Script + * + * Tests the core functions for extracting headings, ToC entries, + * generating slugs, and validating ToC synchronization. + */ + +import { describe, expect, it } from "vitest"; + +import { extractHeadings, extractTocEntries, slugify, validateTocContent } from "./check-toc"; + +// ============================================================================ +// slugify() Tests +// ============================================================================ + +describe("slugify", () => { + describe("basic transformations", () => { + it("should convert simple text to lowercase slug", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("should handle single word", () => { + expect(slugify("Features")).toBe("features"); + }); + + it("should collapse multiple spaces to single hyphen", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("should handle mixed case", () => { + expect(slugify("HeLLo WoRLD")).toBe("hello-world"); + }); + }); + + describe("special characters", () => { + it("should remove & but keep resulting double hyphens", () => { + expect(slugify("Error Handling & Reference")).toBe("error-handling--reference"); + }); + + it("should remove / but keep resulting double hyphens", () => { + expect(slugify("Problem: High token usage / costs")).toBe("problem-high-token-usage--costs"); + }); + + it("should remove colons", () => { + expect(slugify("Option 1: Factory Function")).toBe("option-1-factory-function"); + }); + + it("should remove parentheses", () => { + expect(slugify("createSAPAIProvider(options?)")).toBe("createsapaiprovideroptions"); + }); + + it("should remove question marks and exclamation marks", () => { + expect(slugify("What is this?")).toBe("what-is-this"); + expect(slugify("Hello World!")).toBe("hello-world"); + }); + + it("should handle backticks", () => { + expect(slugify("`code` Example")).toBe("code-example"); + }); + }); + + describe("numbers", () => { + it("should preserve numbers", () => { + expect(slugify("Version 2.0")).toBe("version-20"); + }); + + it("should handle numbered lists", () => { + expect(slugify("1. First Step")).toBe("1-first-step"); + }); + }); + + describe("edge cases", () => { + it("should handle empty string", () => { + expect(slugify("")).toBe(""); + }); + + it("should handle whitespace only", () => { + expect(slugify(" ")).toBe(""); + }); + + it("should trim leading/trailing hyphens", () => { + expect(slugify("- Hello -")).toBe("hello"); + }); + + it("should handle existing hyphens", () => { + expect(slugify("pre-commit hooks")).toBe("pre-commit-hooks"); + }); + + it("should handle underscores (preserved as word chars)", () => { + expect(slugify("my_function_name")).toBe("my_function_name"); + }); + }); + + describe("punctuation and quotes", () => { + it("should remove apostrophes", () => { + expect(slugify("it's working")).toBe("its-working"); + }); + + it("should remove possessive apostrophes", () => { + expect(slugify("user's guide")).toBe("users-guide"); + }); + + it("should remove double quotes", () => { + expect(slugify('The "best" option')).toBe("the-best-option"); + }); + + it("should remove single quotes", () => { + expect(slugify("Don't do this")).toBe("dont-do-this"); + }); + + it("should remove periods (dots)", () => { + expect(slugify("Version 2.0.1")).toBe("version-201"); + }); + + it("should remove commas", () => { + expect(slugify("Hello, World")).toBe("hello-world"); + }); + + it("should remove semicolons", () => { + expect(slugify("Part A; Part B")).toBe("part-a-part-b"); + }); + + it("should handle multiple punctuation marks", () => { + expect(slugify("What's this? It's a test!")).toBe("whats-this-its-a-test"); + }); + }); + + describe("github-slugger behavior differences (documented)", () => { + it("should trim leading hyphens (differs from GitHub)", () => { + expect(slugify("-heading")).toBe("heading"); + }); + + it("should trim trailing hyphens (differs from GitHub)", () => { + expect(slugify("heading-")).toBe("heading"); + }); + + it("should trim both leading and trailing hyphens", () => { + expect(slugify("-heading-")).toBe("heading"); + }); + + it("should handle heading with dash prefix/suffix in text", () => { + expect(slugify("- Hello -")).toBe("hello"); + }); + + it("should convert tabs and newlines to hyphens (differs from GitHub)", () => { + expect(slugify("hello\tworld")).toBe("hello-world"); + expect(slugify("hello\nworld")).toBe("hello-world"); + }); + }); + + describe("symbols and special characters", () => { + it("should remove @ symbol", () => { + expect(slugify("@username mention")).toBe("username-mention"); + }); + + it("should remove # symbol", () => { + expect(slugify("Issue #123")).toBe("issue-123"); + }); + + it("should remove $ symbol", () => { + expect(slugify("Price: $100")).toBe("price-100"); + }); + + it("should remove % symbol", () => { + expect(slugify("100% complete")).toBe("100-complete"); + }); + + it("should remove ^ symbol", () => { + expect(slugify("x^2 formula")).toBe("x2-formula"); + }); + + it("should remove + symbol", () => { + expect(slugify("C++ Programming")).toBe("c-programming"); + }); + + it("should remove = symbol but keep double hyphens from spaces", () => { + expect(slugify("a = b")).toBe("a--b"); + }); + + it("should remove < and > symbols", () => { + expect(slugify("Array")).toBe("arraystring"); + }); + + it("should remove curly braces but keep double hyphens", () => { + expect(slugify("Object { key }")).toBe("object--key"); + }); + + it("should remove square brackets", () => { + expect(slugify("Array[0]")).toBe("array0"); + }); + + it("should remove pipe symbol but keep double hyphens", () => { + expect(slugify("Option A | Option B")).toBe("option-a--option-b"); + }); + + it("should remove backslash", () => { + expect(slugify("path\\to\\file")).toBe("pathtofile"); + }); + }); +}); + +// ============================================================================ +// extractHeadings() Tests +// ============================================================================ + +describe("extractHeadings", () => { + describe("basic heading extraction", () => { + it("should extract h1 heading", () => { + const content = "# Main Title\n\nSome content"; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(1); + expect(headings[0]).toEqual({ + baseSlug: "main-title", + level: 1, + slug: "main-title", + text: "Main Title", + }); + }); + + it("should extract all heading levels (h1-h6)", () => { + const content = ` +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(6); + expect(headings.map((h) => h.level)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("should extract multiple headings", () => { + const content = ` +# Title +## Section 1 +### Subsection +## Section 2 +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(4); + expect(headings.map((h) => h.text)).toEqual([ + "Title", + "Section 1", + "Subsection", + "Section 2", + ]); + }); + }); + + describe("code block handling", () => { + it("should exclude headings inside triple backtick code blocks", () => { + const content = ` +# Real Heading + +\`\`\`markdown +# Heading in code block +\`\`\` + +## Another Real Heading +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(2); + expect(headings.map((h) => h.text)).toEqual(["Real Heading", "Another Real Heading"]); + }); + + it("should exclude headings inside tilde code blocks", () => { + const content = ` +# Real Heading + +~~~ +# Heading in code block +~~~ + +## Another Real Heading +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(2); + }); + + it("should handle code blocks with language specifier", () => { + const content = ` +# Real Heading + +\`\`\`typescript +# This is a comment not a heading +const x = 1; +\`\`\` +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(1); + expect(headings[0]?.text).toBe("Real Heading"); + }); + }); + + describe("markdown formatting in headings", () => { + it("should remove bold formatting (**text**)", () => { + const content = "## **Bold** Heading"; + const headings = extractHeadings(content); + + expect(headings[0]?.text).toBe("Bold Heading"); + }); + + it("should remove italic formatting (*text*)", () => { + const content = "## *Italic* Heading"; + const headings = extractHeadings(content); + + expect(headings[0]?.text).toBe("Italic Heading"); + }); + + it("should remove inline code (`text`)", () => { + const content = "## The `code` Example"; + const headings = extractHeadings(content); + + expect(headings[0]?.text).toBe("The code Example"); + }); + + it("should remove links but keep text ([text](url))", () => { + const content = "## See [Documentation](https://example.com)"; + const headings = extractHeadings(content); + + expect(headings[0]?.text).toBe("See Documentation"); + }); + + it("should handle mixed formatting", () => { + const content = "## **Bold** and *italic* with `code`"; + const headings = extractHeadings(content); + + expect(headings[0]?.text).toBe("Bold and italic with code"); + }); + }); + + describe("duplicate headings", () => { + it("should add suffix for duplicate headings", () => { + const content = ` +## Features +## Features +## Features +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(3); + expect(headings[0]?.slug).toBe("features"); + expect(headings[1]?.slug).toBe("features-1"); + expect(headings[2]?.slug).toBe("features-2"); + }); + + it("should track baseSlug separately from slug", () => { + const content = ` +## Summary +## Details +## Summary +`; + const headings = extractHeadings(content); + + expect(headings[0]).toMatchObject({ + baseSlug: "summary", + slug: "summary", + }); + expect(headings[2]).toMatchObject({ + baseSlug: "summary", + slug: "summary-1", + }); + }); + + it("should handle many duplicates correctly", () => { + const content = ` +## Test +## Test +## Test +## Test +## Test +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(5); + expect(headings.map((h) => h.slug)).toEqual(["test", "test-1", "test-2", "test-3", "test-4"]); + }); + + it("should handle interleaved duplicates", () => { + const content = ` +## Alpha +## Beta +## Alpha +## Gamma +## Beta +## Alpha +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(6); + expect(headings.map((h) => h.slug)).toEqual([ + "alpha", + "beta", + "alpha-1", + "gamma", + "beta-1", + "alpha-2", + ]); + }); + + it("should handle heading that looks like a duplicate suffix", () => { + const content = ` +## Example +## Example +## Example-1 +`; + const headings = extractHeadings(content); + + // Known limitation: tracks by baseSlug, not final slug + expect(headings).toHaveLength(3); + expect(headings[0]?.slug).toBe("example"); + expect(headings[1]?.slug).toBe("example-1"); + expect(headings[2]?.baseSlug).toBe("example-1"); + expect(headings[2]?.slug).toBe("example-1"); + }); + + it("should not conflict explicit numbered headings with duplicate suffixes", () => { + const content = ` +## Step 1 +## Step 2 +## Step 1 +`; + const headings = extractHeadings(content); + + expect(headings).toHaveLength(3); + expect(headings.map((h) => h.slug)).toEqual(["step-1", "step-2", "step-1-1"]); + }); + }); + + describe("edge cases", () => { + it("should return empty array for content with no headings", () => { + const content = "Just some regular text\n\nMore text"; + const headings = extractHeadings(content); + + expect(headings).toEqual([]); + }); + + it("should handle empty content", () => { + expect(extractHeadings("")).toEqual([]); + }); + + it("should ignore lines that look like headings but aren't", () => { + const content = ` +#Not a heading (no space) + # Also not (leading space) +`; + const headings = extractHeadings(content); + + expect(headings).toEqual([]); + }); + + it("should handle headings with special characters", () => { + const content = "## Error Handling & Reference"; + const headings = extractHeadings(content); + + expect(headings[0]?.slug).toBe("error-handling--reference"); + }); + }); +}); + +// ============================================================================ +// extractTocEntries() Tests +// ============================================================================ + +describe("extractTocEntries", () => { + describe("basic extraction", () => { + it("should extract ToC entries from standard format", () => { + const content = ` +# Title + +## Table of Contents + +- [Features](#features) +- [Installation](#installation) + +## Features +`; + const entries = extractTocEntries(content); + + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ slug: "features", text: "Features" }); + expect(entries[1]).toEqual({ + slug: "installation", + text: "Installation", + }); + }); + + it("should extract nested ToC entries", () => { + const content = ` +## Table of Contents + +- [Section 1](#section-1) + - [Subsection 1.1](#subsection-11) + - [Subsection 1.2](#subsection-12) +- [Section 2](#section-2) + +## Section 1 +`; + const entries = extractTocEntries(content); + + expect(entries).toHaveLength(4); + expect(entries.map((e) => e.slug)).toEqual([ + "section-1", + "subsection-11", + "subsection-12", + "section-2", + ]); + }); + }); + + describe("edge cases", () => { + it("should return empty array when no ToC section exists", () => { + const content = ` +# Title + +## Features + +Some content +`; + const entries = extractTocEntries(content); + + expect(entries).toEqual([]); + }); + + it("should be case-insensitive for ToC header", () => { + const content = ` +## TABLE OF CONTENTS + +- [Features](#features) + +## Features +`; + const entries = extractTocEntries(content); + + expect(entries).toHaveLength(1); + }); + + it("should handle ToC with no links", () => { + const content = ` +## Table of Contents + +This ToC has no links, just text. + +## Features +`; + const entries = extractTocEntries(content); + + expect(entries).toEqual([]); + }); + + it("should stop at next h2 heading", () => { + const content = ` +## Table of Contents + +- [Features](#features) + +## Features + +Some regular [link](#not-in-toc) in content. +`; + const entries = extractTocEntries(content); + + expect(entries).toHaveLength(1); + expect(entries[0]?.slug).toBe("features"); + }); + + it("should handle ToC entries with special characters in text", () => { + const content = ` +## Table of Contents + +- [Error Handling & Reference](#error-handling--reference) +- [\`createSAPAIProvider()\`](#createsapaiprovider) + +## Features +`; + const entries = extractTocEntries(content); + + expect(entries).toHaveLength(2); + expect(entries[0]?.text).toBe("Error Handling & Reference"); + expect(entries[1]?.text).toBe("`createSAPAIProvider()`"); + }); + }); +}); + +// ============================================================================ +// validateTocContent() Tests +// ============================================================================ + +describe("validateTocContent", () => { + describe("valid ToCs", () => { + it("should pass for valid ToC with matching headings", () => { + const content = ` +# Title + +## Table of Contents + +- [Features](#features) +- [Installation](#installation) + +## Features + +Content here. + +## Installation + +More content. +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.skipped).toBe(false); + }); + + it("should pass for ToC with nested entries", () => { + const content = ` +## Table of Contents + +- [Section 1](#section-1) + - [Subsection](#subsection) +- [Section 2](#section-2) + +## Section 1 + +### Subsection + +## Section 2 +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + }); + }); + + describe("skipped files", () => { + it("should skip file with no ToC", () => { + const content = ` +# Title + +## Features + +Content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + expect(result.skipped).toBe(true); + expect(result.errors).toEqual([]); + }); + }); + + describe("broken links", () => { + it("should detect ToC link with no matching heading", () => { + const content = ` +## Table of Contents + +- [Features](#features) +- [Nonexistent](#nonexistent) + +## Features + +Content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain("nonexistent"); + }); + + it("should suggest similar slugs when available", () => { + const content = ` +## Table of Contents + +- [Features](#feature) + +## Features + +Content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Did you mean"); + expect(result.errors[0]).toContain("features"); + }); + }); + + describe("missing headings in ToC", () => { + it("should detect h2 heading not in ToC", () => { + const content = ` +## Table of Contents + +- [Features](#features) + +## Features + +Content + +## Installation + +More content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes("Installation"))).toBe(true); + }); + + it("should not complain about missing h3 headings", () => { + const content = ` +## Table of Contents + +- [Features](#features) + +## Features + +### Subsection not in ToC + +Content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + }); + }); + + describe("duplicate headings", () => { + it("should handle duplicate headings with correct suffixes", () => { + const content = ` +## Table of Contents + +- [Summary](#summary) +- [Details](#details) +- [Summary (again)](#summary-1) + +## Summary + +First summary. + +## Details + +Details here. + +## Summary + +Second summary. +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + }); + + it("should accept base slug for duplicate headings", () => { + const content = ` +## Table of Contents + +- [Summary](#summary) +- [Summary (Part 2)](#summary) + +## Summary + +First. + +## Summary + +Second. +`; + // Both ToC entries point to "summary" which should match both headings + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + }); + }); + + describe("special characters in headings", () => { + it("should handle & in headings correctly", () => { + const content = ` +## Table of Contents + +- [Error Handling & Reference](#error-handling--reference) + +## Error Handling & Reference + +Content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + }); + + it("should handle / in headings correctly", () => { + const content = ` +## Table of Contents + +- [Problem: High usage / costs](#problem-high-usage--costs) + +## Problem: High usage / costs + +Content +`; + const result = validateTocContent(content); + + expect(result.valid).toBe(true); + }); + }); +}); diff --git a/scripts/check-toc.ts b/scripts/check-toc.ts new file mode 100644 index 0000000..dcdfb79 --- /dev/null +++ b/scripts/check-toc.ts @@ -0,0 +1,257 @@ +/** + * ToC (Table of Contents) Validation Script + * + * Validates that ToCs in Markdown files are synchronized with their headings. + * @module scripts/check-toc + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// ============================================================================ +// Types +// ============================================================================ + +/** Heading extracted from markdown content */ +export interface Heading { + baseSlug: string; + level: number; + slug: string; + text: string; +} + +/** ToC entry extracted from markdown content */ +export interface TocEntry { + slug: string; + text: string; +} + +/** Result of ToC validation for a single file */ +export interface ValidationResult { + errors: string[]; + skipped: boolean; + valid: boolean; +} + +// ============================================================================ +// Core Functions +// ============================================================================ + +/** + * Extract headings from markdown content, excluding code blocks. + * Handles duplicate headings with GitHub-style suffixes (-1, -2, etc.). + * @param content - Markdown file content + * @returns Array of extracted headings with slugs + */ +export function extractHeadings(content: string): Heading[] { + const headings: Heading[] = []; + const slugCounts = new Map(); + let inCodeBlock = false; + + for (const line of content.split("\n")) { + if (/^(`{3,}|~{3,})/.test(line.trim())) { + inCodeBlock = !inCodeBlock; + continue; + } + + if (inCodeBlock) continue; + + const match = /^(#{1,6})\s+(.+)$/.exec(line); + if (match?.[1] && match[2]) { + const level = match[1].length; + const rawText = match[2]; + + const text = rawText + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .trim(); + + const baseSlug = slugify(text); + const count = slugCounts.get(baseSlug) ?? 0; + slugCounts.set(baseSlug, count + 1); + + const slug = count === 0 ? baseSlug : `${baseSlug}-${String(count)}`; + + headings.push({ baseSlug, level, slug, text }); + } + } + + return headings; +} + +/** + * Extract ToC entries from markdown content (case-insensitive). + * @param content - Markdown file content + * @returns Array of ToC entries + */ +export function extractTocEntries(content: string): TocEntry[] { + const entries: TocEntry[] = []; + + const tocMatch = /##\s+Table of Contents\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i.exec(content); + if (!tocMatch?.[1]) return entries; + + const tocSection = tocMatch[1]; + const linkRegex = /\[([^\]]+)\]\(#([^)]+)\)/g; + let match: null | RegExpExecArray; + while ((match = linkRegex.exec(tocSection)) !== null) { + const text = match[1]; + const slug = match[2]; + if (text && slug) { + entries.push({ slug, text }); + } + } + + return entries; +} + +/** + * Run ToC validation on specified files or all .md files in current directory. + * @param args - File paths to validate + * @returns Exit code (0 for success, 1 for errors) + */ +export function run(args: string[]): number { + let files = args; + + if (files.length === 0) { + files = readdirSync(".") + .filter((f) => f.endsWith(".md")) + .map((f) => join(".", f)); + } + + let hasErrors = false; + let checkedCount = 0; + let skippedCount = 0; + + for (const file of files) { + try { + const result = validateToc(file); + + if (result.skipped) { + skippedCount++; + continue; + } + + checkedCount++; + + if (!result.valid) { + hasErrors = true; + console.error(`\x1b[31m✗ ${file}\x1b[0m`); + for (const error of result.errors) { + console.error(` - ${error}`); + } + } else { + console.log(`\x1b[32m✓ ${file}\x1b[0m`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`\x1b[31m✗ ${file}: ${message}\x1b[0m`); + hasErrors = true; + } + } + + console.log( + `\nChecked ${String(checkedCount)} file(s), skipped ${String(skippedCount)} (no ToC)`, + ); + + if (hasErrors) { + console.error("\n\x1b[31mToC validation failed\x1b[0m"); + return 1; + } else { + console.log("\x1b[32mAll ToCs are valid\x1b[0m"); + return 0; + } +} + +/** + * Generate GitHub-compatible slug from heading text. + * Note: Does NOT collapse multiple hyphens from removed special chars. + * @param text - Heading text to slugify + * @returns GitHub-compatible anchor slug + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\w-]/g, "") + .replace(/^-+|-+$/g, ""); +} + +/** + * Validate ToC against actual headings in a file. + * @param filePath - Path to the markdown file + * @returns Validation result + */ +export function validateToc(filePath: string): ValidationResult { + const content = readFileSync(filePath, "utf-8"); + return validateTocContent(content); +} + +// ============================================================================ +// CLI Entry Point +// ============================================================================ + +/** + * Validate ToC against actual headings from content string. + * @param content - Markdown content to validate + * @returns Validation result + */ +export function validateTocContent(content: string): ValidationResult { + const errors: string[] = []; + + const tocEntries = extractTocEntries(content); + if (tocEntries.length === 0) { + return { errors: [], skipped: true, valid: true }; + } + + const headings = extractHeadings(content); + + const validSlugs = new Set(); + for (const h of headings) { + if (h.text.toLowerCase() !== "table of contents") { + validSlugs.add(h.slug); + validSlugs.add(h.baseSlug); + } + } + + for (const entry of tocEntries) { + if (!validSlugs.has(entry.slug)) { + const similar = [...validSlugs].find( + (s) => s.includes(entry.slug.replace(/-\d+$/, "")) || entry.slug.includes(s), + ); + if (similar) { + errors.push(`ToC link "#${entry.slug}" not found. Did you mean "#${similar}"?`); + } else { + errors.push(`ToC link "#${entry.slug}" (${entry.text}) has no matching heading`); + } + } + } + + const tocSlugs = new Set(tocEntries.map((e) => e.slug)); + for (const heading of headings) { + if ( + heading.level === 2 && + heading.text.toLowerCase() !== "table of contents" && + !tocSlugs.has(heading.slug) && + !tocSlugs.has(heading.baseSlug) + ) { + errors.push(`Heading "${heading.text}" (h2) is not in ToC`); + } + } + + return { errors, skipped: false, valid: errors.length === 0 }; +} + +// Run CLI if executed directly +const isMainModule = + typeof process !== "undefined" && + process.argv[1] && + (process.argv[1].endsWith("check-toc.ts") || process.argv[1].endsWith("check-toc.js")); + +if (isMainModule) { + const exitCode = run(process.argv.slice(2)); + process.exit(exitCode); +} diff --git a/src/convert-to-sap-messages.test.ts b/src/convert-to-sap-messages.test.ts index a996f9b..854b69c 100644 --- a/src/convert-to-sap-messages.test.ts +++ b/src/convert-to-sap-messages.test.ts @@ -1,157 +1,802 @@ -import { describe, it, expect } from "vitest"; +/** + * Tests conversion from Vercel AI SDK prompt format to SAP AI SDK ChatMessage format. + * @see convertToSAPMessages + */ +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; + +import { InvalidPromptError } from "@ai-sdk/provider"; +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; + import { convertToSAPMessages } from "./convert-to-sap-messages"; -import type { LanguageModelV2Prompt } from "@ai-sdk/provider"; + +const createUserPrompt = (text: string): LanguageModelV3Prompt => [ + { content: [{ text, type: "text" }], role: "user" }, +]; +const createSystemPrompt = (content: string): LanguageModelV3Prompt => [ + { content, role: "system" }, +]; describe("convertToSAPMessages", () => { - it("should convert system message", () => { - const prompt: LanguageModelV2Prompt = [ - { role: "system", content: "You are a helpful assistant." }, - ]; + describe("system messages", () => { + it("should convert system message", () => { + const result = convertToSAPMessages(createSystemPrompt("You are helpful.")); + expect(result).toEqual([{ content: "You are helpful.", role: "system" }]); + }); - const result = convertToSAPMessages(prompt); + it("should handle empty system message", () => { + const result = convertToSAPMessages([{ content: "", role: "system" }]); + expect(result).toEqual([{ content: "", role: "system" }]); + }); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - role: "system", - content: "You are a helpful assistant.", + it("should handle large system messages without truncation (100KB+)", () => { + const largeContent = "S".repeat(100000) + "[SYSTEM_END]"; + const prompt: LanguageModelV3Prompt = [{ content: largeContent, role: "system" }]; + const result = convertToSAPMessages(prompt); + expect((result[0] as { content: string }).content).toBe(largeContent); + expect((result[0] as { content: string }).content).toContain("[SYSTEM_END]"); }); }); - it("should convert simple user message", () => { - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Hello!" }] }, - ]; + describe("user messages", () => { + it("should convert simple text message", () => { + const result = convertToSAPMessages(createUserPrompt("Hello!")); + expect(result).toEqual([{ content: "Hello!", role: "user" }]); + }); - const result = convertToSAPMessages(prompt); + it("should convert multiple text parts as array", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "First part.", type: "text" }, + { text: "Second part.", type: "text" }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { + content: [ + { text: "First part.", type: "text" }, + { text: "Second part.", type: "text" }, + ], + role: "user", + }, + ]); + }); + + it("should handle empty content array", () => { + const result = convertToSAPMessages([{ content: [], role: "user" }]); + expect(result).toEqual([{ content: [], role: "user" }]); + }); + + it("should handle large user text messages without truncation (100KB+)", () => { + const largeText = "A".repeat(100000) + "[END_MARKER]"; + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: largeText, type: "text" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt); + expect((result[0] as { content: string }).content).toBe(largeText); + expect((result[0] as { content: string }).content).toHaveLength( + 100000 + "[END_MARKER]".length, + ); + }); + + it("should preserve Unicode characters in large text without corruption", () => { + const unicodeMix = + "中文".repeat(10000) + + "😀🎉🚀".repeat(5000) + + "éèêë".repeat(10000) + + "مرحبا".repeat(5000) + + "[UNICODE_END]"; + + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: unicodeMix, type: "text" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt); + const content = (result[0] as { content: string }).content; - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - role: "user", - content: "Hello!", + expect(content).toBe(unicodeMix); + expect(content).toContain("中文"); + expect(content).toContain("😀"); + expect(content).toContain("éèêë"); + expect(content).toContain("مرحبا"); + expect(content).toContain("[UNICODE_END]"); }); + + it.each([0, 1, 1024, 2048, 4096, 8192, 16384, 32768, 65536])( + "should handle content at boundary size %i without truncation", + (size) => { + const text = "X".repeat(size); + const prompt: LanguageModelV3Prompt = [{ content: [{ text, type: "text" }], role: "user" }]; + const result = convertToSAPMessages(prompt); + expect((result[0] as { content: string }).content).toHaveLength(size); + }, + ); }); - it("should convert user message with image", () => { - const prompt: LanguageModelV2Prompt = [ + describe("assistant messages", () => { + it("should convert text message", () => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: "Hello there!", type: "text" }], role: "assistant" }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { content: "Hello there!", role: "assistant", tool_calls: undefined }, + ]); + }); + + it("should handle empty content array", () => { + const result = convertToSAPMessages([{ content: [], role: "assistant" }]); + expect(result).toEqual([{ content: "", role: "assistant", tool_calls: undefined }]); + }); + + it("should handle large assistant text messages without truncation (100KB+)", () => { + const largeText = "B".repeat(100000) + "[ASSISTANT_END]"; + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: largeText, type: "text" }], role: "assistant" }, + ]; + const result = convertToSAPMessages(prompt); + expect((result[0] as { content: string }).content).toBe(largeText); + expect((result[0] as { content: string }).content).toContain("[ASSISTANT_END]"); + }); + + it("should concatenate multiple large text parts without truncation", () => { + const part1 = "X".repeat(50000) + "[PART1]"; + const part2 = "Y".repeat(50000) + "[PART2]"; + const part3 = "Z".repeat(50000) + "[PART3]"; + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: part1, type: "text" }, + { text: part2, type: "text" }, + { text: part3, type: "text" }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + const content = (result[0] as { content: string }).content; + expect(content).toBe(part1 + part2 + part3); + expect(content).toContain("[PART1]"); + expect(content).toContain("[PART2]"); + expect(content).toContain("[PART3]"); + expect(content).toHaveLength(150000 + 7 * 3); + }); + }); + + describe("reasoning handling", () => { + const reasoningPrompt: LanguageModelV3Prompt = [ { - role: "user", content: [ - { type: "text", text: "What is this?" }, - { - type: "file", - mediaType: "image/png", - data: "base64data", - }, + { text: "Hidden chain of thought", type: "reasoning" }, + { text: "Final answer", type: "text" }, ], + role: "assistant", }, ]; - const result = convertToSAPMessages(prompt); + it("should drop reasoning by default", () => { + const result = convertToSAPMessages(reasoningPrompt); + expect(result[0]).toEqual({ + content: "Final answer", + role: "assistant", + tool_calls: undefined, + }); + }); + + it("should include reasoning when enabled", () => { + const result = convertToSAPMessages(reasoningPrompt, { includeReasoning: true }); + expect(result[0]).toEqual({ + content: "Hidden chain of thoughtFinal answer", + role: "assistant", + tool_calls: undefined, + }); + }); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - role: "user", - content: [ - { type: "text", text: "What is this?" }, + it("should handle reasoning-only message by dropping content", () => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: "Thinking...", type: "reasoning" }], role: "assistant" }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([{ content: "", role: "assistant", tool_calls: undefined }]); + }); + + it.each([ + { description: "empty reasoning text (default)", includeReasoning: false }, + { description: "empty reasoning text (enabled)", includeReasoning: true }, + ])("should not produce think tags for $description", ({ includeReasoning }) => { + const prompt: LanguageModelV3Prompt = [ { - type: "image_url", - image_url: { url: "data:image/png;base64,base64data" }, + content: [ + { text: "", type: "reasoning" }, + { text: "Answer", type: "text" }, + ], + role: "assistant", }, - ], + ]; + const result = convertToSAPMessages(prompt, { includeReasoning }); + expect(result[0]).toEqual({ content: "Answer", role: "assistant", tool_calls: undefined }); + }); + + it("should handle large reasoning text without truncation when enabled", () => { + const largeReasoning = "R".repeat(100000) + "[REASONING_END]"; + const normalText = "Final answer"; + + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: largeReasoning, type: "reasoning" }, + { text: normalText, type: "text" }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt, { includeReasoning: true }); + const content = (result[0] as { content: string }).content; + + expect(content).toBe(`${largeReasoning}${normalText}`); + expect(content).toContain("[REASONING_END]"); + expect(content).toContain("Final answer"); }); }); - it("should convert assistant message with text", () => { - const prompt: LanguageModelV2Prompt = [ - { - role: "assistant", - content: [{ type: "text", text: "Hello there!" }], - }, - ]; + describe("tool calls", () => { + it("should convert tool call with object input", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: { location: "Tokyo" }, + toolCallId: "call_123", + toolName: "get_weather", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { + content: "", + role: "assistant", + tool_calls: [ + { + function: { arguments: '{"location":"Tokyo"}', name: "get_weather" }, + id: "call_123", + type: "function", + }, + ], + }, + ]); + }); + + it("should not double-encode JSON string input", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: '{"location":"Tokyo"}', + toolCallId: "call_123", + toolName: "get_weather", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { tool_calls: { function: { arguments: string } }[] }; + const toolCall = message.tool_calls[0]; + expect(toolCall).toBeDefined(); + expect(toolCall?.function.arguments).toBe('{"location":"Tokyo"}'); + }); + + it("should convert multiple tool calls in single message", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: { location: "Tokyo" }, + toolCallId: "call_1", + toolName: "get_weather", + type: "tool-call", + }, + { + input: { timezone: "JST" }, + toolCallId: "call_2", + toolName: "get_time", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + expect((result[0] as { tool_calls: unknown[] }).tool_calls).toHaveLength(2); + }); + + it("should handle text and tool calls together", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "Let me check.", type: "text" }, + { + input: { location: "Paris" }, + toolCallId: "call_123", + toolName: "get_weather", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { + content: "Let me check.", + role: "assistant", + tool_calls: [ + { + function: { + arguments: '{"location":"Paris"}', + name: "get_weather", + }, + id: "call_123", + type: "function", + }, + ], + }, + ]); + }); + + it("should handle special characters in input", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: { query: 'test "quotes" and \\ backslash' }, + toolCallId: "call_special", + toolName: "search", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { tool_calls: { function: { arguments: string } }[] }; + const toolCall = message.tool_calls[0]; + expect(toolCall).toBeDefined(); + expect(JSON.parse(toolCall?.function.arguments ?? "{}")).toEqual({ + query: 'test "quotes" and \\ backslash', + }); + }); + + it("should handle tool calls with large JSON arguments without truncation (50KB+)", () => { + const largeArray = Array.from({ length: 1000 }, (_, i) => ({ + data: "D".repeat(50), + id: i, + marker: i === 999 ? "LAST_ITEM" : `item_${String(i)}`, + })); + const largeArgs = { finalMarker: "TOOL_END", items: largeArray }; - const result = convertToSAPMessages(prompt); + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: largeArgs, + toolCallId: "call_large", + toolName: "process_large", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { tool_calls: { function: { arguments: string } }[] }; + const toolCall = message.tool_calls[0]; + expect(toolCall).toBeDefined(); + const argsString = toolCall?.function.arguments ?? ""; - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - role: "assistant", - content: "Hello there!", - tool_calls: undefined, + const parsed = JSON.parse(argsString) as { + finalMarker: string; + items: { marker: string }[]; + }; + expect(parsed.items).toHaveLength(1000); + expect(parsed.finalMarker).toBe("TOOL_END"); + const lastItem = parsed.items[999]; + expect(lastItem).toBeDefined(); + expect(lastItem?.marker).toBe("LAST_ITEM"); + }); + + it("should preserve large string input as-is when valid JSON", () => { + const largeObject = { data: "E".repeat(50000), marker: "STRING_JSON_END" }; + const largeJsonString = JSON.stringify(largeObject); + + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: largeJsonString, + toolCallId: "call_string", + toolName: "process_string", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { tool_calls: { function: { arguments: string } }[] }; + const toolCall = message.tool_calls[0]; + expect(toolCall).toBeDefined(); + const argsString = toolCall?.function.arguments ?? ""; + + expect(argsString).toBe(largeJsonString); + const parsed = JSON.parse(argsString) as { data: string; marker: string }; + expect(parsed.marker).toBe("STRING_JSON_END"); + }); + + it("should handle special JSON characters in large tool arguments", () => { + const specialContent = { + backslashes: "\\".repeat(10000), + marker: "SPECIAL_END", + newlines: "\n".repeat(10000), + quotes: '"'.repeat(10000), + tabs: "\t".repeat(10000), + unicode: "\u0000\u001f\u007f".repeat(1000), + }; + + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: specialContent, + toolCallId: "call_special", + toolName: "process_special", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { tool_calls: { function: { arguments: string } }[] }; + const toolCall = message.tool_calls[0]; + expect(toolCall).toBeDefined(); + const argsString = toolCall?.function.arguments ?? ""; + + const parsed = JSON.parse(argsString) as typeof specialContent; + expect(parsed.quotes).toHaveLength(10000); + expect(parsed.backslashes).toHaveLength(10000); + expect(parsed.newlines).toHaveLength(10000); + expect(parsed.tabs).toHaveLength(10000); + expect(parsed.marker).toBe("SPECIAL_END"); }); }); - it("should convert assistant message with tool calls", () => { - const prompt: LanguageModelV2Prompt = [ - { - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: "call_123", - toolName: "get_weather", - input: { location: "Tokyo" }, - }, - ], - }, - ]; + describe("tool results", () => { + it("should convert single tool result", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + output: { type: "json" as const, value: { weather: "sunny" } }, + toolCallId: "call_123", + toolName: "get_weather", + type: "tool-result", + }, + ], + role: "tool", + }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { + content: '{"type":"json","value":{"weather":"sunny"}}', + role: "tool", + tool_call_id: "call_123", + }, + ]); + }); + + it("should convert multiple tool results into separate messages", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + output: { type: "json" as const, value: { weather: "sunny" } }, + toolCallId: "call_1", + toolName: "get_weather", + type: "tool-result", + }, + { + output: { type: "json" as const, value: { time: "12:00" } }, + toolCallId: "call_2", + toolName: "get_time", + type: "tool-result", + }, + ], + role: "tool", + }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { + content: '{"type":"json","value":{"weather":"sunny"}}', + role: "tool", + tool_call_id: "call_1", + }, + { + content: '{"type":"json","value":{"time":"12:00"}}', + role: "tool", + tool_call_id: "call_2", + }, + ]); + }); + + it("should handle empty tool content array", () => { + const result = convertToSAPMessages([{ content: [], role: "tool" }]); + expect(result).toHaveLength(0); + }); + + it("should handle complex nested output", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + output: { + type: "json" as const, + value: { nested: { array: [1, 2, { deep: true }], null_value: null } }, + }, + toolCallId: "call_nested", + toolName: "get_data", + type: "tool-result", + }, + ], + role: "tool", + }, + ]; + const result = convertToSAPMessages(prompt); + const parsed = JSON.parse((result[0] as { content: string }).content) as unknown; + expect(parsed).toEqual({ + type: "json", + value: { nested: { array: [1, 2, { deep: true }], null_value: null } }, + }); + }); - const result = convertToSAPMessages(prompt); + it("should handle tool results with large JSON output without truncation (100KB+)", () => { + const largeOutput = { + type: "json" as const, + value: { + items: Array.from({ length: 2000 }, (_, i) => ({ + content: "F".repeat(50), + id: i, + })), + resultMarker: "RESULT_END", + }, + }; - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - role: "assistant", - content: "", - tool_calls: [ + const prompt: LanguageModelV3Prompt = [ { - id: "call_123", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"Tokyo"}', - }, + content: [ + { + output: largeOutput, + toolCallId: "call_result", + toolName: "get_data", + type: "tool-result", + }, + ], + role: "tool", }, - ], + ]; + const result = convertToSAPMessages(prompt); + const content = (result[0] as { content: string }).content; + const parsed = JSON.parse(content) as typeof largeOutput; + + expect(parsed.value.items).toHaveLength(2000); + expect(parsed.value.resultMarker).toBe("RESULT_END"); }); }); - it("should convert tool result message", () => { - const prompt: LanguageModelV2Prompt = [ - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_123", - toolName: "get_weather", - output: { type: "json" as const, value: { weather: "sunny" } }, - }, - ], - }, - ]; + describe("multi-modal (images)", () => { + it("should convert image with base64 data", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "What is this?", type: "text" }, + { data: "base64data", mediaType: "image/png", type: "file" }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + expect(result).toEqual([ + { + content: [ + { text: "What is this?", type: "text" }, + { image_url: { url: "data:image/png;base64,base64data" }, type: "image_url" }, + ], + role: "user", + }, + ]); + }); + + it("should convert image URL directly", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "Describe this", type: "text" }, + { + data: new URL("https://example.com/image.jpg"), + mediaType: "image/jpeg", + type: "file", + }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { content: { image_url: { url: string } }[] }; + const content = message.content[1]; + expect(content).toBeDefined(); + expect(content?.image_url.url).toBe("https://example.com/image.jpg"); + }); + + it("should convert multiple images", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "Compare", type: "text" }, + { data: "data1", mediaType: "image/png", type: "file" }, + { data: "data2", mediaType: "image/jpeg", type: "file" }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + expect((result[0] as { content: unknown[] }).content).toHaveLength(3); + }); + + it.each([ + { data: new Uint8Array([137, 80, 78, 71]), description: "Uint8Array" }, + { data: Buffer.from([137, 80, 78, 71]), description: "Buffer" }, + ])("should convert $description image data to base64", ({ data }) => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ data, mediaType: "image/png", type: "file" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { content: { image_url: { url: string } }[] }; + const content = message.content[0]; + expect(content).toBeDefined(); + expect(content?.image_url.url).toMatch(/^data:image\/png;base64,iVBORw==/); + }); + + it("should convert buffer-like object with toString", () => { + const bufferLike = { + toString: (encoding?: string) => (encoding === "base64" ? "aGVsbG8=" : "hello"), + }; + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { data: bufferLike as unknown as Uint8Array, mediaType: "image/png", type: "file" }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { content: { image_url: { url: string } }[] }; + const content = message.content[0]; + expect(content).toBeDefined(); + expect(content?.image_url.url).toBe("data:image/png;base64,aGVsbG8="); + }); + + it("should handle large base64 image data without truncation (1MB+)", () => { + const largeBase64 = "A".repeat(1000000) + "END"; + + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "Analyze this image", type: "text" }, + { data: largeBase64, mediaType: "image/png", type: "file" }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { content: { image_url?: { url: string }; type: string }[] }; + const imageContent = message.content.find((c) => c.type === "image_url"); + + expect(imageContent).toBeDefined(); + const imageUrl = imageContent?.image_url?.url; + expect(imageUrl).toBeDefined(); + expect(imageUrl).toBe(`data:image/png;base64,${largeBase64}`); + expect(imageUrl).toContain("END"); + }); + + it("should handle large Uint8Array image data without truncation", () => { + const size = 100000; + const largeData = new Uint8Array(size); + for (let i = 0; i < size; i++) { + largeData[i] = i % 256; + } - const result = convertToSAPMessages(prompt); + const prompt: LanguageModelV3Prompt = [ + { + content: [{ data: largeData, mediaType: "image/jpeg", type: "file" }], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt); + const message = result[0] as { content: { image_url: { url: string } }[] }; + const content = message.content[0]; + expect(content).toBeDefined(); + const url = content?.image_url.url ?? ""; - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: '{"type":"json","value":{"weather":"sunny"}}', + expect(url).toMatch(/^data:image\/jpeg;base64,/); + const base64Part = url.replace("data:image/jpeg;base64,", ""); + const decoded = Buffer.from(base64Part, "base64"); + expect(decoded).toHaveLength(size); }); }); - it("should convert full conversation", () => { - const prompt: LanguageModelV2Prompt = [ - { role: "system", content: "You are helpful." }, - { role: "user", content: [{ type: "text", text: "Hi" }] }, - { role: "assistant", content: [{ type: "text", text: "Hello!" }] }, - { role: "user", content: [{ type: "text", text: "Thanks" }] }, - ]; + describe("full conversation", () => { + it("should convert multi-turn conversation", () => { + const prompt: LanguageModelV3Prompt = [ + { content: "You are helpful.", role: "system" }, + { content: [{ text: "Hi", type: "text" }], role: "user" }, + { content: [{ text: "Hello!", type: "text" }], role: "assistant" }, + { content: [{ text: "Thanks", type: "text" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt); + expect(result.map((m) => m.role)).toEqual(["system", "user", "assistant", "user"]); + }); + }); - const result = convertToSAPMessages(prompt); + describe("error handling", () => { + it.each([ + { description: "audio", mediaType: "audio/mp3" }, + { description: "pdf", mediaType: "application/pdf" }, + { description: "video", mediaType: "video/mp4" }, + ])("should throw for unsupported file type: $description", ({ mediaType }) => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ data: "base64data", mediaType, type: "file" }], role: "user" }, + ]; + expect(() => convertToSAPMessages(prompt)).toThrow("Only image files are supported"); + }); + + it("should throw for unknown user content type", () => { + const prompt = [ + { + content: [ + { data: "data", type: "unknown_type" } as unknown as { text: string; type: "text" }, + ], + role: "user", + }, + ] as LanguageModelV3Prompt; + expect(() => convertToSAPMessages(prompt)).toThrow("Content type unknown_type"); + }); - expect(result).toHaveLength(4); - expect(result[0].role).toBe("system"); - expect(result[1].role).toBe("user"); - expect(result[2].role).toBe("assistant"); - expect(result[3].role).toBe("user"); + it("should throw for unsupported image data type", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [{ data: null as unknown as Uint8Array, mediaType: "image/png", type: "file" }], + role: "user", + }, + ]; + expect(() => convertToSAPMessages(prompt)).toThrow("Unsupported file data type for image"); + }); + + it("should throw InvalidPromptError for unsupported role", () => { + const prompt = [ + { content: "Test", role: "unsupported_role" }, + ] as unknown as LanguageModelV3Prompt; + expect(() => convertToSAPMessages(prompt)).toThrow(InvalidPromptError); + expect(() => convertToSAPMessages(prompt)).toThrow("Unsupported role: unsupported_role"); + }); }); }); diff --git a/src/convert-to-sap-messages.ts b/src/convert-to-sap-messages.ts index 4e99255..ae68f1d 100644 --- a/src/convert-to-sap-messages.ts +++ b/src/convert-to-sap-messages.ts @@ -1,126 +1,228 @@ -import { - LanguageModelV2Prompt, - UnsupportedFunctionalityError, -} from "@ai-sdk/provider"; import type { + AssistantChatMessage, ChatMessage, SystemChatMessage, - UserChatMessage, - AssistantChatMessage, ToolChatMessage, + UserChatMessage, } from "@sap-ai-sdk/orchestration"; +import { + InvalidPromptError, + LanguageModelV3Prompt, + UnsupportedFunctionalityError, +} from "@ai-sdk/provider"; +import { Buffer } from "node:buffer"; + /** - * User chat message content item for multi-modal messages. + * Converts Vercel AI SDK prompt format to SAP AI SDK ChatMessage format. + * + * Supports text messages, multi-modal (text + images), tool calls/results, and reasoning parts. + * Images must be data URLs or HTTPS URLs. Audio and non-image files are not supported. + * Reasoning parts are dropped by default; enable `includeReasoning` to preserve as `...`. + * @module convert-to-sap-messages + * @see {@link https://sdk.vercel.ai/docs/ai-sdk-core/prompt-engineering Vercel AI SDK Prompt Engineering} + * @see {@link https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/orchestration SAP AI Core Orchestration} + */ + +/** + * Options for converting Vercel AI SDK prompts to SAP AI SDK messages. + */ +export interface ConvertToSAPMessagesOptions { + /** + * Whether to include assistant reasoning parts in the converted messages. + * + * When false (default), reasoning content is dropped. + * When true, reasoning is preserved as `...` markers. + */ + readonly includeReasoning?: boolean; +} + +/** + * Multi-modal content item for user messages. + * @internal */ interface UserContentItem { - type: "text" | "image_url"; - text?: string; - image_url?: { - url: string; + readonly image_url?: { + readonly url: string; }; + readonly text?: string; + readonly type: "image_url" | "text"; } /** * Converts Vercel AI SDK prompt format to SAP AI SDK ChatMessage format. - * - * This function transforms the standardized LanguageModelV2Prompt format - * used by the Vercel AI SDK into the ChatMessage format expected - * by SAP AI SDK's OrchestrationClient. - * - * **Supported Features:** - * - Text messages (system, user, assistant) - * - Multi-modal messages (text + images) - * - Tool calls and tool results - * - Conversation history - * - * **Limitations:** - * - Images must be in data URL format or accessible HTTP URLs - * - Audio messages are not supported - * - File attachments (non-image) are not supported - * - * @param prompt - The Vercel AI SDK prompt to convert - * @returns Array of SAP AI SDK compatible ChatMessage objects - * - * @throws {UnsupportedFunctionalityError} When unsupported message types are encountered - * + * @param prompt - The Vercel AI SDK prompt to convert. + * @param options - Conversion settings. + * @returns Array of SAP AI SDK compatible ChatMessage objects. + * @throws {UnsupportedFunctionalityError} When unsupported message types are encountered. * @example * ```typescript * const prompt = [ * { role: 'system', content: 'You are a helpful assistant.' }, * { role: 'user', content: [{ type: 'text', text: 'Hello!' }] } * ]; - * - * const sapMessages = convertToSAPMessages(prompt); - * // Result: [ - * // { role: 'system', content: 'You are a helpful assistant.' }, - * // { role: 'user', content: 'Hello!' } - * // ] - * ``` - * - * @example - * **Multi-modal with Image** - * ```typescript - * const prompt = [ - * { - * role: 'user', - * content: [ - * { type: 'text', text: 'What do you see in this image?' }, - * { type: 'file', mediaType: 'image/jpeg', data: 'base64...' } - * ] - * } - * ]; - * * const sapMessages = convertToSAPMessages(prompt); * ``` */ export function convertToSAPMessages( - prompt: LanguageModelV2Prompt, + prompt: LanguageModelV3Prompt, + options: ConvertToSAPMessagesOptions = {}, ): ChatMessage[] { const messages: ChatMessage[] = []; + const includeReasoning = options.includeReasoning ?? false; for (const message of prompt) { switch (message.role) { + case "assistant": { + let text = ""; + const toolCalls: { + function: { arguments: string; name: string }; + id: string; + type: "function"; + }[] = []; + + for (const part of message.content) { + switch (part.type) { + case "reasoning": { + if (includeReasoning && part.text) { + text += `${part.text}`; + } + break; + } + case "text": { + text += part.text; + break; + } + case "tool-call": { + // Normalize tool call input to JSON string (Vercel AI SDK provides strings or objects) + let argumentsJson: string; + if (typeof part.input === "string") { + try { + JSON.parse(part.input); + argumentsJson = part.input; + } catch { + argumentsJson = JSON.stringify(part.input); + } + } else { + argumentsJson = JSON.stringify(part.input); + } + + toolCalls.push({ + function: { + arguments: argumentsJson, + name: part.toolName, + }, + id: part.toolCallId, + type: "function", + }); + break; + } + } + } + + const assistantMessage: AssistantChatMessage = { + content: text || "", + role: "assistant", + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }; + messages.push(assistantMessage); + break; + } + case "system": { const systemMessage: SystemChatMessage = { - role: "system", content: message.content, + role: "system", }; messages.push(systemMessage); break; } + case "tool": { + for (const part of message.content) { + if (part.type === "tool-result") { + const toolMessage: ToolChatMessage = { + content: JSON.stringify(part.output), + role: "tool", + tool_call_id: part.toolCallId, + }; + messages.push(toolMessage); + } + } + break; + } + case "user": { - // Build content parts for user messages const contentParts: UserContentItem[] = []; for (const part of message.content) { switch (part.type) { - case "text": { - contentParts.push({ - type: "text", - text: part.text, - }); - break; - } case "file": { - // SAP AI Core only supports image files if (!part.mediaType.startsWith("image/")) { throw new UnsupportedFunctionalityError({ functionality: "Only image files are supported", }); } - const imageUrl = - part.data instanceof URL - ? part.data.toString() - : `data:${part.mediaType};base64,${String(part.data)}`; + const supportedFormats = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + ]; + if (!supportedFormats.includes(part.mediaType.toLowerCase())) { + console.warn( + `Image format ${part.mediaType} may not be supported by all models. ` + + `Recommended formats: PNG, JPEG, GIF, WebP`, + ); + } + + let imageUrl: string; + if (part.data instanceof URL) { + imageUrl = part.data.toString(); + } else if (typeof part.data === "string") { + imageUrl = `data:${part.mediaType};base64,${part.data}`; + } else if (part.data instanceof Uint8Array) { + const base64Data = Buffer.from(part.data).toString("base64"); + imageUrl = `data:${part.mediaType};base64,${base64Data}`; + } else if (Buffer.isBuffer(part.data)) { + const base64Data = Buffer.from(part.data).toString("base64"); + imageUrl = `data:${part.mediaType};base64,${base64Data}`; + } else { + const maybeBufferLike = part.data as unknown; + + if ( + maybeBufferLike !== null && + typeof maybeBufferLike === "object" && + "toString" in (maybeBufferLike as Record) + ) { + const base64Data = ( + maybeBufferLike as { + toString: (encoding?: string) => string; + } + ).toString("base64"); + imageUrl = `data:${part.mediaType};base64,${base64Data}`; + } else { + throw new UnsupportedFunctionalityError({ + functionality: + "Unsupported file data type for image. Expected URL, base64 string, or Uint8Array.", + }); + } + } contentParts.push({ - type: "image_url", image_url: { url: imageUrl, }, + type: "image_url", + }); + break; + } + case "text": { + contentParts.push({ + text: part.text, + type: "text", }); break; } @@ -132,78 +234,28 @@ export function convertToSAPMessages( } } - // If only text content, use simple string format - // Otherwise use array format for multi-modal + const firstPart = contentParts[0]; const userMessage: UserChatMessage = - contentParts.length === 1 && contentParts[0].type === "text" + contentParts.length === 1 && firstPart?.type === "text" ? { + content: firstPart.text ?? "", role: "user", - content: contentParts[0].text ?? "", } : { - role: "user", content: contentParts as UserChatMessage["content"], + role: "user", }; messages.push(userMessage); break; } - case "assistant": { - let text = ""; - const toolCalls: { - id: string; - type: "function"; - function: { name: string; arguments: string }; - }[] = []; - - for (const part of message.content) { - switch (part.type) { - case "text": { - text += part.text; - break; - } - case "tool-call": { - toolCalls.push({ - id: part.toolCallId, - type: "function", - function: { - name: part.toolName, - arguments: JSON.stringify(part.input), - }, - }); - break; - } - } - } - - const assistantMessage: AssistantChatMessage = { - role: "assistant", - content: text || "", - tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - }; - messages.push(assistantMessage); - break; - } - - case "tool": { - // Convert tool results to tool messages - for (const part of message.content) { - const toolMessage: ToolChatMessage = { - role: "tool", - tool_call_id: part.toolCallId, - content: JSON.stringify(part.output), - }; - messages.push(toolMessage); - } - break; - } - default: { const _exhaustiveCheck: never = message; - throw new Error( - `Unsupported role: ${(_exhaustiveCheck as { role: string }).role}`, - ); + throw new InvalidPromptError({ + message: `Unsupported role: ${(_exhaustiveCheck as { role: string }).role}`, + prompt: JSON.stringify(message), + }); } } } diff --git a/src/deep-merge.test.ts b/src/deep-merge.test.ts new file mode 100644 index 0000000..4da0882 --- /dev/null +++ b/src/deep-merge.test.ts @@ -0,0 +1,410 @@ +/** + * Unit tests for deep merge utility. + * + * Tests recursive object merging, array replacement, special type handling, + * prototype pollution protection, and circular reference detection. + */ + +import { describe, expect, it } from "vitest"; + +import { deepMerge, deepMergeTwo } from "./deep-merge"; + +describe("deepMerge", () => { + describe("basic merging", () => { + it("should merge two flat objects", () => { + const result = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 } as Record); + expect(result).toEqual({ a: 1, b: 3, c: 4 }); + }); + + it("should merge multiple objects", () => { + const result = deepMerge>({ a: 1 }, { b: 2 }, { c: 3 }); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("should return empty object when no sources provided", () => { + const result = deepMerge(); + expect(result).toEqual({}); + }); + + it("should handle undefined sources", () => { + const result = deepMerge>({ a: 1 }, undefined, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("should handle null sources", () => { + const result = deepMerge>({ a: 1 }, null as never, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + }); + + describe("nested object merging", () => { + it("should deeply merge nested objects", () => { + const result = deepMerge>( + { a: { b: 1, c: 2 }, d: 3 }, + { a: { c: 3, e: 4 }, f: 5 }, + ); + expect(result).toEqual({ + a: { b: 1, c: 3, e: 4 }, + d: 3, + f: 5, + }); + }); + + it("should merge deeply nested objects", () => { + const result = deepMerge>( + { a: { b: { c: { d: 1 } } } }, + { a: { b: { c: { e: 2 } } } }, + ); + expect(result).toEqual({ + a: { b: { c: { d: 1, e: 2 } } }, + }); + }); + + it("should handle mixed nested and flat properties", () => { + const result = deepMerge>( + { a: 1, b: { c: 2 } }, + { a: 2, b: { d: 3 }, e: 4 }, + ); + expect(result).toEqual({ + a: 2, + b: { c: 2, d: 3 }, + e: 4, + }); + }); + + it("should preserve nested objects from first source if not overridden", () => { + const result = deepMerge>({ a: { b: { c: 1 } }, d: 2 }, { d: 3 }); + expect(result).toEqual({ + a: { b: { c: 1 } }, + d: 3, + }); + }); + }); + + describe("array handling", () => { + it("should replace arrays, not merge them", () => { + const result = deepMerge>({ arr: [1, 2, 3] }, { arr: [4, 5] }); + expect(result).toEqual({ arr: [4, 5] }); + }); + + it("should handle arrays in nested objects", () => { + const result = deepMerge>( + { a: { arr: [1, 2] } }, + { a: { arr: [3, 4, 5] } }, + ); + expect(result).toEqual({ + a: { arr: [3, 4, 5] }, + }); + }); + + it("should handle empty arrays", () => { + const result = deepMerge>({ arr: [1, 2, 3] }, { arr: [] }); + expect(result).toEqual({ arr: [] }); + }); + }); + + describe("primitive value handling", () => { + it.each([ + { expected: null, original: 1, override: null, type: "null" }, + { expected: undefined, original: 1, override: undefined, type: "undefined" }, + { expected: false, original: true, override: false, type: "boolean (false)" }, + { expected: true, original: false, override: true, type: "boolean (true)" }, + { expected: "world", original: "hello", override: "world", type: "string" }, + { expected: 100, original: 42, override: 100, type: "number" }, + { expected: 0, original: 1, override: 0, type: "zero" }, + { expected: "", original: "hello", override: "", type: "empty string" }, + ])("should override with $type", ({ expected, original, override }) => { + const result = deepMerge>({ a: original }, { a: override }); + expect(result).toEqual({ a: expected }); + }); + }); + + describe("special object types", () => { + it.each([ + { factory: () => [new Date("2024-01-01"), new Date("2024-12-31")], type: "Date" }, + { factory: () => [/foo/, /bar/], type: "RegExp" }, + { factory: () => [new Map([["a", 1]]), new Map([["b", 2]])], type: "Map" }, + { factory: () => [new Set([1, 2]), new Set([3, 4])], type: "Set" }, + { + factory: () => [() => "first", () => "second"], + type: "Function", + }, + ])("should replace $type objects (not merge)", ({ factory }) => { + const [obj1, obj2] = factory(); + const result = deepMerge>({ value: obj1 }, { value: obj2 }); + expect(result.value).toBe(obj2); + }); + + it("should preserve Symbol values (not as keys)", () => { + const sym1 = Symbol("first"); + const sym2 = Symbol("second"); + const result = deepMerge>({ value: sym1 }, { value: sym2 }); + expect(result.value).toBe(sym2); + }); + }); + + describe("immutability", () => { + it("should not mutate source objects", () => { + const source1 = { a: { b: 1 } }; + const source2 = { a: { c: 2 } }; + + deepMerge>(source1, source2); + + expect(source1).toEqual({ a: { b: 1 } }); + expect(source2).toEqual({ a: { c: 2 } }); + }); + + it("should create new nested objects", () => { + const source1 = { a: { b: 1 } }; + const result = deepMerge>(source1, { a: { c: 2 } }); + + expect(result.a).not.toBe(source1.a); + }); + }); + + describe("edge cases", () => { + it("should handle objects with null prototype", () => { + const obj = Object.create(null) as Record; + obj.a = 1; + + const result = deepMerge>(obj, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("should handle objects with numeric keys", () => { + const result = deepMerge>( + { "0": "a", "1": "b" }, + { "1": "c", "2": "d" }, + ); + expect(result).toEqual({ "0": "a", "1": "c", "2": "d" }); + }); + + it("should ignore symbol keys (only string keys are processed)", () => { + const sym1 = Symbol("test1"); + const sym2 = Symbol("test2"); + + const result = deepMerge>( + { [sym1]: "value1" }, + { [sym2]: "value2" }, + ); + + expect(result[sym1]).toBeUndefined(); + expect(result[sym2]).toBeUndefined(); + }); + + it("should handle class instances with inherited properties", () => { + class Base { + inherited = "base"; + } + class Extended extends Base { + own = "extended"; + } + + const obj = new Extended(); + const result = deepMerge>(obj as unknown as Record, { + other: "value", + }); + + expect(result.other).toBe("value"); + expect(result.own).toBe("extended"); + expect(result.inherited).toBe("base"); + }); + }); + + describe("security", () => { + describe("prototype pollution protection", () => { + it.each([ + { description: "proto", key: "__proto__" }, + { description: "constructor", key: "constructor" }, + { description: "prototype", key: "prototype" }, + ])("should skip $description key to prevent prototype pollution", ({ key }) => { + const malicious = { [key]: { polluted: true } }; + const result = deepMerge>({}, malicious); + + expect(result).toEqual({}); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it("should handle nested __proto__ attempts", () => { + const malicious = JSON.parse('{"a": {"__proto__": {"polluted": true}}}') as Record< + string, + unknown + >; + const result = deepMerge>({ a: { safe: true } }, malicious); + + expect(result).toEqual({ a: { safe: true } }); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it.each([ + { json: '{"safe": "value", "__proto__": {"polluted": true}}', key: "__proto__" }, + { json: '{"safe": "value", "constructor": {"polluted": true}}', key: "constructor" }, + ])("should skip $key when cloning source-only plain objects", ({ json }) => { + const malicious = { + newKey: JSON.parse(json) as Record, + }; + const result = deepMerge>({}, malicious); + + expect(result.newKey).toEqual({ safe: "value" }); + expect(({} as Record).polluted).toBeUndefined(); + }); + }); + + describe("circular reference detection", () => { + it("should throw error on circular reference", () => { + const obj: Record = { a: 1 }; + obj.self = obj; + + expect(() => deepMerge>({}, obj)).toThrow( + "Circular reference detected during deep merge", + ); + }); + + it("should throw error on deeply nested circular reference", () => { + const obj: Record = { a: { b: { c: {} } } }; + (obj.a as Record).b = obj; + + expect(() => deepMerge>({}, obj)).toThrow( + "Circular reference detected during deep merge", + ); + }); + + it("should allow same object in different sources", () => { + const shared = { value: 1 }; + const result = deepMerge>({ a: shared }, { b: shared }); + + expect(result).toEqual({ a: { value: 1 }, b: { value: 1 } }); + }); + }); + + describe("depth limit protection", () => { + it("should throw error when max depth exceeded", () => { + let deepObj: Record = { value: "deep" }; + for (let i = 0; i < 150; i++) { + deepObj = { nested: deepObj }; + } + + expect(() => deepMerge>({}, deepObj)).toThrow( + "Maximum merge depth exceeded", + ); + }); + + it("should handle objects within depth limit", () => { + let deepObj: Record = { value: "deep" }; + for (let i = 0; i < 50; i++) { + deepObj = { nested: deepObj }; + } + + const result = deepMerge>({}, deepObj); + expect(result).toHaveProperty("nested"); + }); + }); + }); + + describe("real-world scenarios", () => { + it("should merge modelParams from settings and providerOptions", () => { + const settings = { + modelParams: { + customParam: "from-settings", + nested: { a: 1, b: 2 }, + temperature: 0.3, + }, + }; + + const providerOptions = { + modelParams: { + customParam: "from-provider", + nested: { b: 3, c: 4 }, + }, + }; + + const result = deepMerge>(settings, providerOptions); + + expect(result.modelParams).toEqual({ + customParam: "from-provider", + nested: { a: 1, b: 3, c: 4 }, + temperature: 0.3, + }); + }); + + it("should merge three levels: defaults + settings + providerOptions", () => { + const defaults = { + modelParams: { maxTokens: 1000, temperature: 0.5 }, + }; + + const settings = { + modelParams: { customField: "custom", temperature: 0.7 }, + }; + + const providerOptions = { + modelParams: { customField: "override" }, + }; + + const result = deepMerge>(defaults, settings, providerOptions); + + expect(result.modelParams).toEqual({ + customField: "override", + maxTokens: 1000, + temperature: 0.7, + }); + }); + + it("should handle complex nested configuration", () => { + const base = { + modelParams: { + config: { + advanced: { option1: true, option2: false }, + basic: { timeout: 1000 }, + }, + simple: "value", + }, + }; + + const override = { + modelParams: { + config: { + advanced: { option2: true, option3: true }, + }, + newParam: 42, + }, + }; + + const result = deepMerge>(base, override); + + expect(result.modelParams).toEqual({ + config: { + advanced: { option1: true, option2: true, option3: true }, + basic: { timeout: 1000 }, + }, + newParam: 42, + simple: "value", + }); + }); + }); +}); + +describe("deepMergeTwo", () => { + // Note: deepMergeTwo is a convenience wrapper around deepMerge. + // Core merge behavior (nested, arrays, security, immutability) is tested in deepMerge tests. + // These tests focus on the two-argument API and undefined handling. + + it("should merge two objects with deep merge behavior", () => { + const result = deepMergeTwo>({ a: { b: 1 } }, { a: { c: 2 } }); + expect(result).toEqual({ a: { b: 1, c: 2 } }); + }); + + it("should handle undefined target", () => { + const result = deepMergeTwo>(undefined, { a: 1 }); + expect(result).toEqual({ a: 1 }); + }); + + it("should handle undefined source", () => { + const result = deepMergeTwo>({ a: 1 }, undefined); + expect(result).toEqual({ a: 1 }); + }); + + it("should handle both undefined", () => { + const result = deepMergeTwo(undefined, undefined); + expect(result).toEqual({}); + }); +}); diff --git a/src/deep-merge.ts b/src/deep-merge.ts new file mode 100644 index 0000000..5673188 --- /dev/null +++ b/src/deep-merge.ts @@ -0,0 +1,195 @@ +/** + * Deep merge utility with prototype pollution protection. + * @module deep-merge + */ + +/** Keys blocked to prevent prototype pollution. */ +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** Maximum recursion depth to prevent stack overflow. */ +const MAX_DEPTH = 100; + +/** + * Deep merges multiple objects recursively. + * + * ## Merge Behavior + * - **Plain objects**: Merged recursively (properties combined) + * - **Arrays**: Replaced (last array wins, no concatenation) + * - **Primitives**: Replaced (last value wins) + * - **Special objects** (Date, RegExp, Map, Set, etc.): Replaced (not merged) + * - **null/undefined**: Treated as values (can override) + * + * ## Security + * - Protected against prototype pollution (`__proto__`, `constructor`, `prototype` are blocked) + * - Circular references are detected and throw an error + * + * ## Performance + * - Time complexity: O(n) where n = total number of properties across all levels + * - Space complexity: O(d) where d = maximum nesting depth + * @param sources - Objects to merge (later objects override earlier ones) + * @returns New merged object (source objects are not mutated) + * @throws {Error} If circular reference is detected + * @throws {Error} If maximum recursion depth is exceeded + * @example + * ```typescript + * // Basic deep merge + * const result = deepMerge( + * { a: { b: 1, c: 2 } }, + * { a: { c: 3, d: 4 } } + * ); + * // Result: { a: { b: 1, c: 3, d: 4 } } + * ``` + * @example + * ```typescript + * // Arrays are replaced, not merged + * const result = deepMerge( + * { arr: [1, 2, 3] }, + * { arr: [4, 5] } + * ); + * // Result: { arr: [4, 5] } + * ``` + * @example + * ```typescript + * // Multiple objects + * const result = deepMerge( + * { a: 1 }, + * { b: 2 }, + * { c: 3 } + * ); + * // Result: { a: 1, b: 2, c: 3 } + * ``` + */ +export function deepMerge>( + ...sources: (Partial | undefined)[] +): T { + return mergeInternal(sources as Record[]) as T; +} + +/** + * Deep merges two objects with better type inference for common cases. + * + * This is a convenience wrapper around deepMerge for the common two-object case. + * @param target - Base object. + * @param source - Object to merge into target. + * @returns New merged object. + * @example + * ```typescript + * const defaults = { timeout: 1000, retry: { max: 3, delay: 100 } }; + * const overrides = { retry: { max: 5 } }; + * const config = deepMergeTwo(defaults, overrides); + * // Result: { timeout: 1000, retry: { max: 5, delay: 100 } } + * ``` + */ +export function deepMergeTwo>( + target: T | undefined, + source: Partial | undefined, +): T { + return deepMerge(target, source); +} + +/** + * Deep clones a plain object with circular reference detection. + * @param obj - Object to clone. + * @param seen - Set of visited objects for cycle detection. + * @param depth - Current recursion depth. + * @returns Cloned object. + * @internal + */ +function cloneDeep( + obj: Record, + seen: WeakSet, + depth: number, +): Record { + if (depth > MAX_DEPTH) { + throw new Error("Maximum merge depth exceeded"); + } + if (seen.has(obj)) { + throw new Error("Circular reference detected during deep merge"); + } + seen.add(obj); + + const result: Record = {}; + for (const key of Object.keys(obj)) { + if (!isSafeKey(key)) continue; + const value = obj[key]; + result[key] = isPlainObject(value) ? cloneDeep(value, seen, depth + 1) : value; + } + return result; +} + +/** + * Type guard for plain objects (excludes arrays, Date, RegExp, class instances, etc.). + * @param value - Value to check. + * @returns True if value is a plain object. + * @internal + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value) as unknown; + return proto === Object.prototype || proto === null; +} + +/** + * Checks if a key is safe to merge (not a prototype pollution vector). + * @param key - Object key to check. + * @returns True if key is safe to merge. + * @internal + */ +function isSafeKey(key: string): boolean { + return !DANGEROUS_KEYS.has(key); +} + +/** + * Merges multiple source objects into a single result. + * @param sources - Array of objects to merge. + * @returns Merged result object. + * @internal + */ +function mergeInternal(sources: (Record | undefined)[]): Record { + let result: Record = {}; + for (const source of sources) { + if (source == null) continue; + result = mergeTwo(result, source, new WeakSet(), 0); + } + return result; +} + +/** + * Recursively merges two objects with depth tracking and circular reference detection. + * @param target - Target object to merge into. + * @param source - Source object to merge from. + * @param seen - Set of visited objects for cycle detection. + * @param depth - Current recursion depth. + * @returns Merged result object. + * @internal + */ +function mergeTwo( + target: Record, + source: Record, + seen: WeakSet, + depth: number, +): Record { + if (depth > MAX_DEPTH) { + throw new Error("Maximum merge depth exceeded"); + } + if (seen.has(source)) { + throw new Error("Circular reference detected during deep merge"); + } + seen.add(source); + + for (const key of Object.keys(source)) { + if (!isSafeKey(key)) continue; + + const sourceValue = source[key]; + const targetValue = target[key]; + + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + target[key] = mergeTwo({ ...targetValue }, sourceValue, seen, depth + 1); + } else if (isPlainObject(sourceValue)) { + target[key] = cloneDeep(sourceValue, seen, depth + 1); + } else { + target[key] = sourceValue; + } + } + return target; +} diff --git a/src/index.ts b/src/index.ts index 2c98f5d..3909a02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,55 +1,118 @@ -// Provider exports -export { createSAPAIProvider, sapai } from "./sap-ai-provider"; -export type { - SAPAIProvider, - SAPAIProviderSettings, - DeploymentConfig, -} from "./sap-ai-provider"; +/** + * `@jerome-benoit/sap-ai-provider` + * + * Vercel AI SDK provider for SAP AI Core. + * Wraps the SAP AI SDK to provide Vercel AI SDK-compatible interfaces. + */ + +/** + * Embedding model class for generating vector embeddings via SAP AI Core. + */ +export { SAPAIEmbeddingModel } from "./sap-ai-embedding-model.js"; + +export type { SAPAIEmbeddingModelId, SAPAIEmbeddingSettings } from "./sap-ai-embedding-model.js"; -// Settings and model types -export type { SAPAISettings, SAPAIModelId } from "./sap-ai-chat-settings"; +/** + * Error handling types for SAP AI Core error responses. + */ +export type { OrchestrationErrorResponse } from "./sap-ai-error.js"; -// Re-export masking/filtering module types and helpers from SAP AI SDK -export type { MaskingModule, FilteringModule } from "./sap-ai-chat-settings"; +/** + * Language model class for chat/text completions via SAP AI Core. + */ +export { SAPAILanguageModel } from "./sap-ai-language-model.js"; + +/** + * Provider options for per-call configuration. + * + * These schemas and types enable runtime validation of provider options + * passed via `providerOptions['sap-ai']` in Vercel AI SDK calls. + */ export { - buildDpiMaskingProvider, - buildAzureContentSafetyFilter, - buildLlamaGuard38BFilter, - buildDocumentGroundingConfig, - buildTranslationConfig, -} from "./sap-ai-chat-settings"; + getProviderName, + SAP_AI_PROVIDER_NAME, + sapAIEmbeddingProviderOptions, + sapAILanguageModelProviderOptions, +} from "./sap-ai-provider-options.js"; + +export type { + SAPAIEmbeddingProviderOptions, + SAPAILanguageModelProviderOptions, +} from "./sap-ai-provider-options.js"; + +/** + * Provider factory function and pre-configured default instance. + */ +export { createSAPAIProvider, sapai } from "./sap-ai-provider.js"; -// Error handling -export { SAPAIError } from "./sap-ai-error"; -export type { OrchestrationErrorResponse } from "./sap-ai-error"; +export type { DeploymentConfig, SAPAIProvider, SAPAIProviderSettings } from "./sap-ai-provider.js"; -// Re-export useful types from SAP AI SDK for advanced usage +/** + * Model settings types and model identifier type definitions. + */ +export type { SAPAIModelId, SAPAISettings } from "./sap-ai-settings.js"; + +/** + * SAP AI SDK types and utilities. + * + * Re-exported for convenience and advanced usage scenarios. + */ export type { - OrchestrationModuleConfig, + AssistantChatMessage, ChatCompletionRequest, - PromptTemplatingModule, - GroundingModule, - TranslationModule, - LlmModelParams, - LlmModelDetails, ChatCompletionTool, - FunctionObject, -} from "./types/completion-request"; - -export type { ChatMessage, + DeveloperChatMessage, + DocumentTranslationApplyToSelector, + FilteringModule, + FunctionObject, + GroundingModule, + LlmModelDetails, + LlmModelParams, + MaskingModule, + OrchestrationConfigRef, + OrchestrationModuleConfig, + PromptTemplatingModule, SystemChatMessage, - UserChatMessage, - AssistantChatMessage, ToolChatMessage, - DeveloperChatMessage, -} from "./types/completion-response"; + TranslationApplyToCategory, + TranslationInputParameters, + TranslationModule, + TranslationOutputParameters, + TranslationTargetLanguage, + UserChatMessage, +} from "./sap-ai-settings.js"; +/** + * Helper functions for building configurations. + */ export { + buildAzureContentSafetyFilter, + buildDocumentGroundingConfig, + buildDpiMaskingProvider, + buildLlamaGuard38BFilter, + buildTranslationConfig, +} from "./sap-ai-settings.js"; + +/** + * Response classes from the SAP AI SDK for orchestration results. + */ +export { + OrchestrationEmbeddingResponse, OrchestrationResponse, - OrchestrationStreamResponse, + OrchestrationStream, OrchestrationStreamChunkResponse, -} from "./types/completion-response"; + OrchestrationStreamResponse, +} from "./sap-ai-settings.js"; + +/** + * Package version, injected at build time. + */ +export { VERSION } from "./version.js"; -// Re-export OrchestrationClient for advanced usage -export { OrchestrationClient } from "@sap-ai-sdk/orchestration"; +/** + * Direct access to SAP AI SDK OrchestrationClient. + * + * For advanced users who need to use the SAP AI SDK directly. + */ +export { OrchestrationClient, OrchestrationEmbeddingClient } from "@sap-ai-sdk/orchestration"; diff --git a/src/sap-ai-chat-language-model.test.ts b/src/sap-ai-chat-language-model.test.ts deleted file mode 100644 index e6c43f2..0000000 --- a/src/sap-ai-chat-language-model.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { SAPAIChatLanguageModel } from "./sap-ai-chat-language-model"; -import type { - LanguageModelV2Prompt, - LanguageModelV2FunctionTool, - LanguageModelV2ProviderTool, - LanguageModelV2StreamPart, -} from "@ai-sdk/provider"; - -// Mock the OrchestrationClient -vi.mock("@sap-ai-sdk/orchestration", () => { - class MockOrchestrationClient { - chatCompletion = vi.fn().mockResolvedValue({ - getContent: () => "Hello!", - getToolCalls: () => undefined, - getTokenUsage: () => ({ - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }), - getFinishReason: () => "stop", - }); - - stream = vi.fn().mockResolvedValue({ - stream: { - async *[Symbol.asyncIterator]() { - await Promise.resolve(); - yield { - getDeltaContent: () => "Hello", - getDeltaToolCalls: () => undefined, - getFinishReason: () => null, - getTokenUsage: () => undefined, - }; - yield { - getDeltaContent: () => "!", - getDeltaToolCalls: () => undefined, - getFinishReason: () => "stop", - getTokenUsage: () => ({ - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }), - }; - }, - }, - getTokenUsage: () => ({ - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }), - getFinishReason: () => "stop", - }); - } - - return { - OrchestrationClient: MockOrchestrationClient, - }; -}); - -describe("SAPAIChatLanguageModel", () => { - const createModel = (modelId = "gpt-4o", settings = {}) => { - return new SAPAIChatLanguageModel(modelId, settings, { - provider: "sap-ai", - deploymentConfig: { resourceGroup: "default" }, - }); - }; - - describe("model properties", () => { - it("should have correct specification version", () => { - const model = createModel(); - expect(model.specificationVersion).toBe("v2"); - }); - - it("should have correct model ID", () => { - const model = createModel("gpt-4o"); - expect(model.modelId).toBe("gpt-4o"); - }); - - it("should have correct provider", () => { - const model = createModel(); - expect(model.provider).toBe("sap-ai"); - }); - - it("should support image URLs", () => { - const model = createModel(); - expect(model.supportsImageUrls).toBe(true); - }); - - it("should support structured outputs", () => { - const model = createModel(); - expect(model.supportsStructuredOutputs).toBe(true); - }); - - it("should support HTTPS URLs", () => { - const model = createModel(); - expect(model.supportsUrl(new URL("https://example.com/image.png"))).toBe( - true, - ); - }); - - it("should not support HTTP URLs", () => { - const model = createModel(); - expect(model.supportsUrl(new URL("http://example.com/image.png"))).toBe( - false, - ); - }); - }); - - describe("doGenerate", () => { - it("should generate text response", async () => { - const model = createModel(); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ]; - - const result = await model.doGenerate({ prompt }); - - expect(result.content).toHaveLength(1); - expect(result.content[0]).toEqual({ type: "text", text: "Hello!" }); - expect(result.finishReason).toBe("stop"); - expect(result.usage).toEqual({ - inputTokens: 10, - outputTokens: 5, - totalTokens: 15, - }); - }); - - it("should pass tools to orchestration config", async () => { - const model = createModel(); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "What is 2+2?" }] }, - ]; - - const tools: LanguageModelV2FunctionTool[] = [ - { - type: "function", - name: "calculate", - description: "Perform calculation", - inputSchema: { - type: "object", - properties: { - expression: { type: "string" }, - }, - required: ["expression"], - }, - }, - ]; - - const result = await model.doGenerate({ prompt, tools }); - - expect(result.warnings).toHaveLength(0); - expect(result.rawCall.rawPrompt).toBeDefined(); - }); - - it("should warn about unsupported tool types", async () => { - const model = createModel(); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ]; - - const tools = [ - { - type: "provider-defined" as const, - id: "custom-tool", - args: {}, - }, - ]; - - const result = await model.doGenerate({ - prompt, - tools: tools as unknown as LanguageModelV2ProviderTool[], - }); - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0].type).toBe("unsupported-tool"); - }); - }); - - describe("doStream", () => { - it("should stream text response", async () => { - const model = createModel(); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ]; - - const { stream } = await model.doStream({ prompt }); - - const parts: LanguageModelV2StreamPart[] = []; - const reader = stream.getReader(); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const { done, value } = await reader.read(); - if (done) break; - parts.push(value); - } - - // Check stream structure - expect(parts[0].type).toBe("stream-start"); - expect(parts.some((p) => p.type === "text-delta")).toBe(true); - expect(parts.some((p) => p.type === "finish")).toBe(true); - - // Check finish part - const finishPart = parts.find((p) => p.type === "finish"); - expect(finishPart).toBeDefined(); - if (finishPart?.type === "finish") { - expect(finishPart.finishReason).toBe("stop"); - } - }); - }); - - describe("tool calling configuration", () => { - it("should convert function tools correctly", async () => { - const model = createModel(); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Get weather" }] }, - ]; - - const tools: LanguageModelV2FunctionTool[] = [ - { - type: "function", - name: "get_weather", - description: "Get weather for a location", - inputSchema: { - type: "object", - properties: { - location: { type: "string", description: "City name" }, - }, - required: ["location"], - }, - }, - ]; - - const result = await model.doGenerate({ prompt, tools }); - - // Verify the raw prompt contains the tool configuration - const rawPrompt = result.rawCall.rawPrompt as { - config?: unknown; - }; - expect(rawPrompt.config).toBeDefined(); - }); - - it("should handle multiple tools", async () => { - const model = createModel(); - const prompt: LanguageModelV2Prompt = [ - { - role: "user", - content: [{ type: "text", text: "Calculate and get weather" }], - }, - ]; - - const tools: LanguageModelV2FunctionTool[] = [ - { - type: "function", - name: "calculate", - description: "Calculate expression", - inputSchema: { - type: "object", - properties: { expr: { type: "string" } }, - required: ["expr"], - }, - }, - { - type: "function", - name: "get_weather", - description: "Get weather", - inputSchema: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - ]; - - const result = await model.doGenerate({ prompt, tools }); - expect(result.warnings).toHaveLength(0); - }); - }); - - describe("model-specific behavior", () => { - it("should disable n parameter for Amazon models", async () => { - const model = createModel("amazon--nova-pro", { - modelParams: { n: 2 }, - }); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ]; - - const result = await model.doGenerate({ prompt }); - // The model should still work, n is just ignored - expect(result.content).toBeDefined(); - }); - - it("should disable n parameter for Anthropic models", async () => { - const model = createModel("anthropic--claude-3.5-sonnet", { - modelParams: { n: 2 }, - }); - const prompt: LanguageModelV2Prompt = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ]; - - const result = await model.doGenerate({ prompt }); - expect(result.content).toBeDefined(); - }); - }); -}); diff --git a/src/sap-ai-chat-language-model.ts b/src/sap-ai-chat-language-model.ts deleted file mode 100644 index 834b113..0000000 --- a/src/sap-ai-chat-language-model.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { - LanguageModelV2, - LanguageModelV2CallOptions, - LanguageModelV2CallWarning, - LanguageModelV2Content, - LanguageModelV2FinishReason, - LanguageModelV2FunctionTool, - LanguageModelV2StreamPart, - LanguageModelV2Usage, -} from "@ai-sdk/provider"; -import { - OrchestrationClient, - OrchestrationModuleConfig, - ChatMessage, - ChatCompletionTool, -} from "@sap-ai-sdk/orchestration"; -import type { HttpDestinationOrFetchOptions } from "@sap-cloud-sdk/connectivity"; -import type { - ResourceGroupConfig, - DeploymentIdConfig, -} from "@sap-ai-sdk/ai-api/internal.js"; -// Note: zodToJsonSchema and isZodSchema are kept for potential future use -// when AI SDK Zod conversion issues are resolved -import { zodToJsonSchema } from "zod-to-json-schema"; -// Import ZodSchema from zod/v3 for zod-to-json-schema -import type { ZodSchema } from "zod/v3"; -import { convertToSAPMessages } from "./convert-to-sap-messages"; -import { SAPAIModelId, SAPAISettings } from "./sap-ai-chat-settings"; - -/** - * Type guard to check if an object is a Zod schema. - * @internal - */ -function isZodSchema(obj: unknown): obj is ZodSchema { - return ( - obj !== null && - typeof obj === "object" && - "_def" in obj && - "parse" in obj && - typeof (obj as { parse: unknown }).parse === "function" - ); -} - -/** - * Internal configuration for the SAP AI Chat Language Model. - * @internal - */ -interface SAPAIConfig { - /** Provider identifier */ - provider: string; - /** Deployment configuration for SAP AI SDK */ - deploymentConfig: ResourceGroupConfig | DeploymentIdConfig; - /** Optional custom destination */ - destination?: HttpDestinationOrFetchOptions; -} - -/** - * SAP AI Chat Language Model implementation. - * - * This class implements the Vercel AI SDK's `LanguageModelV2` interface, - * providing a bridge between the AI SDK and SAP AI Core's Orchestration API - * using the official SAP AI SDK (@sap-ai-sdk/orchestration). - * - * **Features:** - * - Text generation (streaming and non-streaming) - * - Tool calling (function calling) - * - Multi-modal input (text + images) - * - Data masking (SAP DPI) - * - Content filtering - * - * **Model Support:** - * - Azure OpenAI models (gpt-4o, gpt-4o-mini, o1, o3, etc.) - * - Google Vertex AI models (gemini-2.0-flash, gemini-2.5-pro, etc.) - * - AWS Bedrock models (anthropic--claude-*, amazon--nova-*, etc.) - * - AI Core open source models (mistralai--, cohere--, etc.) - * - * @example - * ```typescript - * // Create via provider - * const provider = createSAPAIProvider(); - * const model = provider('gpt-4o'); - * - * // Use with AI SDK - * const result = await generateText({ - * model, - * prompt: 'Hello, world!' - * }); - * ``` - * - * @implements {LanguageModelV2} - */ -export class SAPAIChatLanguageModel implements LanguageModelV2 { - /** AI SDK specification version */ - readonly specificationVersion = "v2"; - /** Default object generation mode */ - readonly defaultObjectGenerationMode = "json"; - /** Whether the model supports image URLs */ - readonly supportsImageUrls = true; - /** The model identifier (e.g., 'gpt-4o', 'anthropic--claude-3.5-sonnet') */ - readonly modelId: SAPAIModelId; - /** Whether the model supports structured outputs */ - readonly supportsStructuredOutputs = true; - - /** Internal configuration */ - private readonly config: SAPAIConfig; - /** Model-specific settings */ - private readonly settings: SAPAISettings; - - /** - * Creates a new SAP AI Chat Language Model instance. - * - * @param modelId - The model identifier - * @param settings - Model-specific configuration settings - * @param config - Internal configuration (deployment config, destination, etc.) - * - * @internal This constructor is not meant to be called directly. - * Use the provider function instead. - */ - constructor( - modelId: SAPAIModelId, - settings: SAPAISettings, - config: SAPAIConfig, - ) { - this.settings = settings; - this.config = config; - this.modelId = modelId; - } - - /** - * Checks if a URL is supported for file/image uploads. - * - * @param url - The URL to check - * @returns True if the URL protocol is HTTPS - */ - supportsUrl(url: URL): boolean { - return url.protocol === "https:"; - } - - /** - * Returns supported URL patterns for different content types. - * - * @returns Record of content types to regex patterns - */ - get supportedUrls(): Record { - return { - "image/*": [ - /^https:\/\/.*\.(?:png|jpg|jpeg|gif|webp)$/i, - /^data:image\/.*$/, - ], - }; - } - - /** - * Gets the provider identifier. - * - * @returns The provider name ('sap-ai') - */ - get provider(): string { - return this.config.provider; - } - - /** - * Builds orchestration module config for SAP AI SDK. - * - * @param options - Call options from the AI SDK - * @returns Object containing orchestration config and warnings - * - * @internal - */ - private buildOrchestrationConfig(options: LanguageModelV2CallOptions): { - orchestrationConfig: OrchestrationModuleConfig; - messages: ChatMessage[]; - warnings: LanguageModelV2CallWarning[]; - } { - const warnings: LanguageModelV2CallWarning[] = []; - - // Convert AI SDK prompt to SAP messages - const messages = convertToSAPMessages(options.prompt); - - // Get tools - prefer settings.tools if provided (proper JSON Schema), - // otherwise try to convert from AI SDK tools - let tools: ChatCompletionTool[] | undefined; - - if (this.settings.tools && this.settings.tools.length > 0) { - // Use tools from settings (already in SAP format with proper schemas) - tools = this.settings.tools; - } else { - // Extract tools from options and convert - const availableTools = options.tools; - - tools = availableTools - ?.map((tool): ChatCompletionTool | null => { - if (tool.type === "function") { - // Get the input schema - AI SDK provides this as JSONSchema7 - // But in some cases, it might be a Zod schema or have empty properties - const inputSchema = tool.inputSchema as - | Record - | undefined; - - // Also check for raw Zod schema in 'parameters' field (AI SDK internal) - const toolWithParams = tool as LanguageModelV2FunctionTool & { - parameters?: unknown; - }; - - // Build parameters ensuring type: "object" is always present - // SAP AI Core requires explicit type: "object" in the schema - let parameters: Record; - - // First, check if there's a Zod schema we need to convert - if ( - toolWithParams.parameters && - isZodSchema(toolWithParams.parameters) - ) { - // Convert Zod schema to JSON Schema - const jsonSchema = zodToJsonSchema(toolWithParams.parameters, { - $refStrategy: "none", - }) as Record; - // Remove $schema property as SAP doesn't need it - delete jsonSchema.$schema; - parameters = { - type: "object", - ...jsonSchema, - }; - } else if (inputSchema && Object.keys(inputSchema).length > 0) { - // Check if schema has properties (it's a proper object schema) - const hasProperties = - inputSchema.properties && - typeof inputSchema.properties === "object" && - Object.keys(inputSchema.properties).length > 0; - - if (hasProperties) { - parameters = { - type: "object", - ...inputSchema, - }; - } else { - // Schema exists but has no properties - use default empty schema - parameters = { - type: "object", - properties: {}, - required: [], - }; - } - } else { - // No schema provided - use default empty schema - parameters = { - type: "object", - properties: {}, - required: [], - }; - } - - return { - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters, - }, - }; - } else { - warnings.push({ - type: "unsupported-tool", - tool: tool, - }); - return null; - } - }) - .filter((t): t is ChatCompletionTool => t !== null); - } - - // Check if model supports certain features - const supportsN = - !this.modelId.startsWith("amazon--") && - !this.modelId.startsWith("anthropic--"); - - // Build orchestration config - const orchestrationConfig: OrchestrationModuleConfig = { - promptTemplating: { - model: { - name: this.modelId, - version: this.settings.modelVersion ?? "latest", - params: { - max_tokens: this.settings.modelParams?.maxTokens, - temperature: this.settings.modelParams?.temperature, - top_p: this.settings.modelParams?.topP, - frequency_penalty: this.settings.modelParams?.frequencyPenalty, - presence_penalty: this.settings.modelParams?.presencePenalty, - n: supportsN ? (this.settings.modelParams?.n ?? 1) : undefined, - }, - }, - prompt: { - template: [], - tools: tools && tools.length > 0 ? tools : undefined, - }, - }, - // Include masking module if provided - ...(this.settings.masking ? { masking: this.settings.masking } : {}), - // Include filtering module if provided - ...(this.settings.filtering - ? { filtering: this.settings.filtering } - : {}), - }; - - return { orchestrationConfig, messages, warnings }; - } - - /** - * Creates an OrchestrationClient instance. - * - * @param config - Orchestration module configuration - * @returns OrchestrationClient instance - * - * @internal - */ - private createClient(config: OrchestrationModuleConfig): OrchestrationClient { - return new OrchestrationClient( - config, - this.config.deploymentConfig, - this.config.destination, - ); - } - - /** - * Generates a single completion (non-streaming). - * - * This method implements the `LanguageModelV2.doGenerate` interface, - * sending a request to SAP AI Core and returning the complete response. - * - * **Features:** - * - Tool calling support - * - Multi-modal input (text + images) - * - Data masking (if configured) - * - Content filtering (if configured) - * - * @param options - Generation options including prompt, tools, and settings - * @returns Promise resolving to the generation result with content, usage, and metadata - * - * @example - * ```typescript - * const result = await model.doGenerate({ - * prompt: [ - * { role: 'user', content: [{ type: 'text', text: 'Hello!' }] } - * ] - * }); - * - * console.log(result.content); // Generated content - * console.log(result.usage); // Token usage - * ``` - */ - async doGenerate(options: LanguageModelV2CallOptions): Promise<{ - content: LanguageModelV2Content[]; - finishReason: LanguageModelV2FinishReason; - usage: LanguageModelV2Usage; - rawCall: { rawPrompt: unknown; rawSettings: Record }; - warnings: LanguageModelV2CallWarning[]; - }> { - const { orchestrationConfig, messages, warnings } = - this.buildOrchestrationConfig(options); - - const client = this.createClient(orchestrationConfig); - - const response = await client.chatCompletion({ - messages, - }); - - const content: LanguageModelV2Content[] = []; - - // Extract text content - const textContent = response.getContent(); - if (textContent) { - content.push({ - type: "text", - text: textContent, - }); - } - - // Extract tool calls - const toolCalls = response.getToolCalls(); - if (toolCalls) { - for (const toolCall of toolCalls) { - content.push({ - type: "tool-call", - toolCallId: toolCall.id, - toolName: toolCall.function.name, - // AI SDK expects input as a JSON string, which it parses internally - input: toolCall.function.arguments, - }); - } - } - - // Get usage - const tokenUsage = response.getTokenUsage(); - - // Map finish reason - const finishReasonRaw = response.getFinishReason(); - const finishReason = mapFinishReason(finishReasonRaw); - - return { - content, - finishReason, - usage: { - inputTokens: tokenUsage.prompt_tokens, - outputTokens: tokenUsage.completion_tokens, - totalTokens: tokenUsage.total_tokens, - }, - rawCall: { - rawPrompt: { config: orchestrationConfig, messages }, - rawSettings: {}, - }, - warnings, - }; - } - - /** - * Generates a streaming completion. - * - * This method implements the `LanguageModelV2.doStream` interface, - * sending a streaming request to SAP AI Core and returning a stream of response parts. - * - * **Stream Events:** - * - `stream-start` - Stream initialization - * - `response-metadata` - Response metadata (model, timestamp) - * - `text-start` - Text generation starts - * - `text-delta` - Incremental text chunks - * - `text-end` - Text generation completes - * - `tool-call` - Tool call detected - * - `finish` - Stream completes with usage and finish reason - * - `error` - Error occurred - * - * @param options - Streaming options including prompt, tools, and settings - * @returns Promise resolving to stream and raw call metadata - * - * @example - * ```typescript - * const { stream } = await model.doStream({ - * prompt: [ - * { role: 'user', content: [{ type: 'text', text: 'Write a story' }] } - * ] - * }); - * - * for await (const part of stream) { - * if (part.type === 'text-delta') { - * process.stdout.write(part.delta); - * } - * } - * ``` - */ - async doStream(options: LanguageModelV2CallOptions): Promise<{ - stream: ReadableStream; - rawCall: { rawPrompt: unknown; rawSettings: Record }; - }> { - const { orchestrationConfig, messages, warnings } = - this.buildOrchestrationConfig(options); - - const client = this.createClient(orchestrationConfig); - - const streamResponse = await client.stream( - { messages }, - options.abortSignal, - { promptTemplating: { include_usage: true } }, - ); - - let finishReason: LanguageModelV2FinishReason = "unknown"; - const usage: LanguageModelV2Usage = { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }; - - let isFirstChunk = true; - let activeText = false; - - // Track tool calls being built up - const toolCallsInProgress = new Map< - number, - { id: string; name: string; arguments: string } - >(); - - const sdkStream = streamResponse.stream; - - const transformedStream = new ReadableStream({ - async start(controller) { - controller.enqueue({ type: "stream-start", warnings }); - - try { - for await (const chunk of sdkStream) { - if (isFirstChunk) { - isFirstChunk = false; - controller.enqueue({ - type: "response-metadata", - id: undefined, - modelId: undefined, - timestamp: new Date(), - }); - } - - // Get delta content - const deltaContent = chunk.getDeltaContent(); - if (deltaContent) { - if (!activeText) { - controller.enqueue({ type: "text-start", id: "0" }); - activeText = true; - } - controller.enqueue({ - type: "text-delta", - id: "0", - delta: deltaContent, - }); - } - - // Handle tool calls - const deltaToolCalls = chunk.getDeltaToolCalls(); - if (deltaToolCalls) { - for (const toolCallChunk of deltaToolCalls) { - const index = toolCallChunk.index; - - // Initialize tool call if new - if (!toolCallsInProgress.has(index)) { - toolCallsInProgress.set(index, { - id: toolCallChunk.id ?? `tool_${String(index)}`, - name: toolCallChunk.function?.name ?? "", - arguments: "", - }); - - // Emit tool-input-start - const tc = toolCallsInProgress.get(index); - if (!tc) continue; - if (toolCallChunk.function?.name) { - controller.enqueue({ - type: "tool-input-start", - id: tc.id, - toolName: tc.name, - }); - } - } - - const tc = toolCallsInProgress.get(index); - if (!tc) continue; - - // Update tool call ID if provided - if (toolCallChunk.id) { - tc.id = toolCallChunk.id; - } - - // Update function name if provided - if (toolCallChunk.function?.name) { - tc.name = toolCallChunk.function.name; - } - - // Accumulate arguments - if (toolCallChunk.function?.arguments) { - tc.arguments += toolCallChunk.function.arguments; - controller.enqueue({ - type: "tool-input-delta", - id: tc.id, - delta: toolCallChunk.function.arguments, - }); - } - } - } - - // Check for finish reason - const chunkFinishReason = chunk.getFinishReason(); - if (chunkFinishReason) { - finishReason = mapFinishReason(chunkFinishReason); - } - - // Get usage from chunk - const chunkUsage = chunk.getTokenUsage(); - if (chunkUsage) { - usage.inputTokens = chunkUsage.prompt_tokens; - usage.outputTokens = chunkUsage.completion_tokens; - usage.totalTokens = chunkUsage.total_tokens; - } - } - - // Emit completed tool calls - const toolCalls = Array.from(toolCallsInProgress.values()); - for (const tc of toolCalls) { - controller.enqueue({ - type: "tool-input-end", - id: tc.id, - }); - controller.enqueue({ - type: "tool-call", - toolCallId: tc.id, - toolName: tc.name, - input: tc.arguments, - }); - } - - if (activeText) { - controller.enqueue({ type: "text-end", id: "0" }); - } - - // Try to get final usage from stream response - const finalUsage = streamResponse.getTokenUsage(); - if (finalUsage) { - usage.inputTokens = finalUsage.prompt_tokens; - usage.outputTokens = finalUsage.completion_tokens; - usage.totalTokens = finalUsage.total_tokens; - } - - // Get final finish reason - const finalFinishReason = streamResponse.getFinishReason(); - if (finalFinishReason) { - finishReason = mapFinishReason(finalFinishReason); - } - - controller.enqueue({ - type: "finish", - finishReason, - usage, - }); - - controller.close(); - } catch (error) { - controller.enqueue({ - type: "error", - error: error instanceof Error ? error : new Error(String(error)), - }); - controller.close(); - } - }, - }); - - return { - stream: transformedStream, - rawCall: { - rawPrompt: { config: orchestrationConfig, messages }, - rawSettings: {}, - }, - }; - } -} - -/** - * Maps SAP AI Core finish reasons to AI SDK finish reasons. - */ -function mapFinishReason( - reason: string | undefined, -): LanguageModelV2FinishReason { - if (!reason) return "unknown"; - - switch (reason.toLowerCase()) { - case "stop": - return "stop"; - case "length": - return "length"; - case "tool_calls": - case "function_call": - return "tool-calls"; - case "content_filter": - return "content-filter"; - default: - return "unknown"; - } -} diff --git a/src/sap-ai-chat-settings.ts b/src/sap-ai-chat-settings.ts deleted file mode 100644 index e48d6f6..0000000 --- a/src/sap-ai-chat-settings.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { - MaskingModule, - FilteringModule, - ChatModel, - ChatCompletionTool, -} from "@sap-ai-sdk/orchestration"; - -/** - * Settings for configuring SAP AI Core model behavior. - */ -export interface SAPAISettings { - /** - * Specific version of the model to use. - * If not provided, the latest version will be used. - */ - modelVersion?: string; - - /** - * Model generation parameters that control the output. - */ - modelParams?: { - /** - * Maximum number of tokens to generate. - * Higher values allow for longer responses but increase latency and cost. - */ - maxTokens?: number; - - /** - * Sampling temperature between 0 and 2. - * Higher values make output more random, lower values more deterministic. - * No default; omitted when unspecified or unsupported by the target model. - */ - temperature?: number; - - /** - * Nucleus sampling parameter between 0 and 1. - * Controls diversity via cumulative probability cutoff. - * @default 1 - */ - topP?: number; - - /** - * Frequency penalty between -2.0 and 2.0. - * Positive values penalize tokens based on their frequency. - * @default 0 - */ - frequencyPenalty?: number; - - /** - * Presence penalty between -2.0 and 2.0. - * Positive values penalize tokens that have appeared in the text. - * @default 0 - */ - presencePenalty?: number; - - /** - * Number of completions to generate. - * Multiple completions provide alternative responses. - * Note: Not supported by Amazon and Anthropic models. - * @default 1 - */ - n?: number; - - /** - * Whether to enable parallel tool calls. - * When enabled, the model can call multiple tools in parallel. - */ - parallel_tool_calls?: boolean; - }; - - /** - * Masking configuration for SAP AI Core orchestration. - * When provided, sensitive information in prompts can be anonymized or - * pseudonymized by SAP Data Privacy Integration (DPI). - * - * @example - * ```typescript - * import { buildDpiMaskingProvider } from '@sap-ai-sdk/orchestration'; - * - * const model = provider('gpt-4o', { - * masking: { - * masking_providers: [ - * buildDpiMaskingProvider({ - * method: 'anonymization', - * entities: ['profile-email', 'profile-phone'] - * }) - * ] - * } - * }); - * ``` - */ - masking?: MaskingModule; - - /** - * Filtering configuration for input and output content safety. - * Supports Azure Content Safety and Llama Guard filters. - * - * @example - * ```typescript - * import { buildAzureContentSafetyFilter } from '@sap-ai-sdk/orchestration'; - * - * const model = provider('gpt-4o', { - * filtering: { - * input: { - * filters: [ - * buildAzureContentSafetyFilter('input', { - * hate: 'ALLOW_SAFE', - * violence: 'ALLOW_SAFE_LOW_MEDIUM' - * }) - * ] - * } - * } - * }); - * ``` - */ - filtering?: FilteringModule; - - /** - * Response format for templating prompt (OpenAI-compatible). - * Allows specifying structured output formats. - * - * @example - * ```typescript - * const model = provider('gpt-4o', { - * responseFormat: { - * type: 'json_schema', - * json_schema: { - * name: 'response', - * schema: { type: 'object', properties: { answer: { type: 'string' } } } - * } - * } - * }); - * ``` - */ - responseFormat?: - | { type: "text" } - | { type: "json_object" } - | { - type: "json_schema"; - json_schema: { - name: string; - description?: string; - schema?: unknown; - strict?: boolean | null; - }; - }; - - /** - * Tool definitions in SAP AI SDK format. - * - * Use this to pass tools directly with proper JSON Schema definitions. - * This bypasses the AI SDK's Zod conversion which may have issues. - * - * Note: This should be used in conjunction with AI SDK's tool handling - * to provide the actual tool implementations (execute functions). - * - * @example - * ```typescript - * const model = provider('gpt-4o', { - * tools: [ - * { - * type: 'function', - * function: { - * name: 'get_weather', - * description: 'Get weather for a location', - * parameters: { - * type: 'object', - * properties: { - * location: { type: 'string', description: 'City name' } - * }, - * required: ['location'] - * } - * } - * } - * ] - * }); - * ``` - */ - tools?: ChatCompletionTool[]; -} - -/** - * Supported model IDs in SAP AI Core. - * - * These models are available through the SAP AI Core Orchestration service. - * Model availability depends on your subscription and region. - * - * **Azure OpenAI Models:** - * - gpt-4o, gpt-4o-mini - * - gpt-4.1, gpt-4.1-mini, gpt-4.1-nano - * - o1, o3, o3-mini, o4-mini - * - * **Google Vertex AI Models:** - * - gemini-2.0-flash, gemini-2.0-flash-lite - * - gemini-2.5-flash, gemini-2.5-pro - * - * **AWS Bedrock Models:** - * - anthropic--claude-3-haiku, anthropic--claude-3-sonnet, anthropic--claude-3-opus - * - anthropic--claude-3.5-sonnet, anthropic--claude-3.7-sonnet - * - anthropic--claude-4-sonnet, anthropic--claude-4-opus - * - amazon--nova-pro, amazon--nova-lite, amazon--nova-micro, amazon--nova-premier - * - * **AI Core Open Source Models:** - * - mistralai--mistral-large-instruct, mistralai--mistral-medium-instruct, mistralai--mistral-small-instruct - * - cohere--command-a-reasoning - */ -export type SAPAIModelId = ChatModel; - -// Re-export useful types from SAP AI SDK for convenience -export type { MaskingModule, FilteringModule } from "@sap-ai-sdk/orchestration"; - -// Re-export DPI masking helpers -export { - buildDpiMaskingProvider, - buildAzureContentSafetyFilter, - buildLlamaGuard38BFilter, - buildDocumentGroundingConfig, - buildTranslationConfig, -} from "@sap-ai-sdk/orchestration"; diff --git a/src/sap-ai-embedding-model.test.ts b/src/sap-ai-embedding-model.test.ts new file mode 100644 index 0000000..57d5806 --- /dev/null +++ b/src/sap-ai-embedding-model.test.ts @@ -0,0 +1,354 @@ +/** + * Tests SAP AI Embedding Model - creation, configuration, and doEmbed behavior. + * @see SAPAIEmbeddingModel + */ +import type { EmbeddingModelV3CallOptions } from "@ai-sdk/provider"; + +import { TooManyEmbeddingValuesForCallError } from "@ai-sdk/provider"; +import { describe, expect, it, vi } from "vitest"; + +import { SAPAIEmbeddingModel } from "./sap-ai-embedding-model.js"; + +// Mock types +interface ConstructorCall { + config: { embeddings: { model: { name: string; params?: Record } } }; + deploymentConfig: unknown; + destination: unknown; +} +interface EmbedCall { + request: { input: string[]; type?: string }; + requestConfig?: { signal?: AbortSignal }; +} +interface EmbeddingResponse { + getEmbeddings: () => { embedding: number[] | string; index: number; object: string }[]; + getTokenUsage: () => { prompt_tokens: number; total_tokens: number }; +} + +vi.mock("@sap-ai-sdk/orchestration", () => { + class MockOrchestrationEmbeddingClient { + static embedError: Error | undefined; + static embedResponse: EmbeddingResponse | undefined; + static lastConstructorCall: ConstructorCall | undefined; + static lastEmbedCall: EmbedCall | undefined; + + embed = vi + .fn() + .mockImplementation( + (request: { input: string[]; type?: string }, requestConfig?: { signal?: AbortSignal }) => { + MockOrchestrationEmbeddingClient.lastEmbedCall = { request, requestConfig }; + const errorToThrow = MockOrchestrationEmbeddingClient.embedError; + if (errorToThrow) { + MockOrchestrationEmbeddingClient.embedError = undefined; + throw errorToThrow; + } + if (MockOrchestrationEmbeddingClient.embedResponse) { + const response = MockOrchestrationEmbeddingClient.embedResponse; + MockOrchestrationEmbeddingClient.embedResponse = undefined; + return Promise.resolve(response); + } + return Promise.resolve({ + getEmbeddings: () => [ + { embedding: [0.1, 0.2, 0.3], index: 0, object: "embedding" }, + { embedding: [0.4, 0.5, 0.6], index: 1, object: "embedding" }, + ], + getTokenUsage: () => ({ prompt_tokens: 8, total_tokens: 8 }), + }); + }, + ); + + constructor( + config: { embeddings: { model: { name: string; params?: Record } } }, + deploymentConfig: unknown, + destination: unknown, + ) { + MockOrchestrationEmbeddingClient.lastConstructorCall = { + config, + deploymentConfig, + destination, + }; + } + } + return { + MockOrchestrationEmbeddingClient, + OrchestrationEmbeddingClient: MockOrchestrationEmbeddingClient, + }; +}); + +interface MockClientType { + MockOrchestrationEmbeddingClient: { + embedError: Error | undefined; + embedResponse: EmbeddingResponse | undefined; + lastConstructorCall: ConstructorCall | undefined; + lastEmbedCall: EmbedCall | undefined; + }; +} + +/** + * Helper to access the mocked SAP AI SDK OrchestrationEmbeddingClient for test manipulation. + * @returns The mock client instance with spied constructor and embed method. + */ +async function getMockClient(): Promise { + const mod = await import("@sap-ai-sdk/orchestration"); + return mod as unknown as MockClientType; +} + +describe("SAPAIEmbeddingModel", () => { + const defaultConfig = { deploymentConfig: { resourceGroup: "default" }, provider: "sap-ai" }; + + describe("model properties", () => { + it("should expose correct interface properties", () => { + const model = new SAPAIEmbeddingModel("text-embedding-3-small", {}, defaultConfig); + expect(model.specificationVersion).toBe("v3"); + expect(model.modelId).toBe("text-embedding-3-small"); + expect(model.provider).toBe("sap-ai"); + expect(model.maxEmbeddingsPerCall).toBe(2048); + expect(model.supportsParallelCalls).toBe(true); + }); + + it("should allow custom maxEmbeddingsPerCall", () => { + const model = new SAPAIEmbeddingModel( + "text-embedding-ada-002", + { maxEmbeddingsPerCall: 100 }, + defaultConfig, + ); + expect(model.maxEmbeddingsPerCall).toBe(100); + }); + }); + + describe("constructor validation", () => { + it("should accept valid modelParams", () => { + expect( + () => + new SAPAIEmbeddingModel( + "text-embedding-3-small", + { modelParams: { dimensions: 1536, encoding_format: "float", normalize: true } }, + defaultConfig, + ), + ).not.toThrow(); + }); + + it("should not throw when modelParams is undefined", () => { + expect( + () => new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig), + ).not.toThrow(); + }); + + it.each([ + { description: "negative dimensions", modelParams: { dimensions: -1 } }, + { description: "non-integer dimensions", modelParams: { dimensions: 1.5 } }, + { description: "invalid encoding_format", modelParams: { encoding_format: "invalid" } }, + { description: "non-boolean normalize", modelParams: { normalize: "true" } }, + ])("should reject $description", ({ modelParams }) => { + expect( + () => + new SAPAIEmbeddingModel( + "text-embedding-ada-002", + { modelParams } as never, + defaultConfig, + ), + ).toThrow(); + }); + }); + + describe("doEmbed", () => { + it("should generate embeddings with correct result structure", async () => { + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + const result = await model.doEmbed({ + values: ["Hello", "World"], + } as EmbeddingModelV3CallOptions); + + expect(result.embeddings).toEqual([ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ]); + expect(result.usage?.tokens).toBe(8); + expect(result.warnings).toEqual([]); + expect(result.providerMetadata).toEqual({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + "sap-ai": { model: "text-embedding-ada-002", version: expect.any(String) }, + }); + }); + + it("should sort embeddings by index when returned out of order", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + MockOrchestrationEmbeddingClient.embedResponse = { + getEmbeddings: () => [ + { embedding: [0.7, 0.8, 0.9], index: 2, object: "embedding" }, + { embedding: [0.1, 0.2, 0.3], index: 0, object: "embedding" }, + { embedding: [0.4, 0.5, 0.6], index: 1, object: "embedding" }, + ], + getTokenUsage: () => ({ prompt_tokens: 12, total_tokens: 12 }), + }; + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + const result = await model.doEmbed({ values: ["A", "B", "C"] }); + + expect(result.embeddings).toEqual([ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + [0.7, 0.8, 0.9], + ]); + }); + + it("should throw TooManyEmbeddingValuesForCallError when exceeding limit", async () => { + const model = new SAPAIEmbeddingModel( + "text-embedding-ada-002", + { maxEmbeddingsPerCall: 2 }, + defaultConfig, + ); + await expect(model.doEmbed({ values: ["A", "B", "C"] })).rejects.toThrow( + TooManyEmbeddingValuesForCallError, + ); + }); + + it("should pass abort signal to SAP SDK", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const abortController = new AbortController(); + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + + await model.doEmbed({ abortSignal: abortController.signal, values: ["Test"] }); + + expect(MockOrchestrationEmbeddingClient.lastEmbedCall?.requestConfig?.signal).toBe( + abortController.signal, + ); + }); + + it("should not pass requestConfig when no abort signal", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + + await model.doEmbed({ values: ["Test"] }); + + expect(MockOrchestrationEmbeddingClient.lastEmbedCall?.requestConfig).toBeUndefined(); + }); + }); + + describe("embedding normalization", () => { + it("should handle base64-encoded embeddings", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const floats = new Float32Array([1.0, 2.0, 3.0]); + const base64 = Buffer.from(floats.buffer).toString("base64"); + MockOrchestrationEmbeddingClient.embedResponse = { + getEmbeddings: () => [{ embedding: base64, index: 0, object: "embedding" }], + getTokenUsage: () => ({ prompt_tokens: 4, total_tokens: 4 }), + }; + + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + const result = await model.doEmbed({ values: ["Test"] }); + + expect(result.embeddings).toEqual([[1.0, 2.0, 3.0]]); + }); + }); + + describe("settings integration", () => { + it.each([ + { description: "default type 'text'", expected: "text", settings: {} }, + { + description: "constructor type 'document'", + expected: "document", + settings: { type: "document" as const }, + }, + ])("should use $description", async ({ expected, settings }) => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", settings, defaultConfig); + + await model.doEmbed({ values: ["Test"] }); + + expect(MockOrchestrationEmbeddingClient.lastEmbedCall?.request.type).toBe(expected); + }); + + it("should pass model params to SDK client", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel( + "text-embedding-3-large", + { modelParams: { dimensions: 256 } }, + defaultConfig, + ); + + await model.doEmbed({ values: ["Test"] }); + + expect(MockOrchestrationEmbeddingClient.lastConstructorCall?.config.embeddings.model).toEqual( + { + name: "text-embedding-3-large", + params: { dimensions: 256 }, + }, + ); + }); + + it("should not include params when modelParams not specified", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + + await model.doEmbed({ values: ["Test"] }); + + expect(MockOrchestrationEmbeddingClient.lastConstructorCall?.config.embeddings.model).toEqual( + { + name: "text-embedding-ada-002", + }, + ); + }); + + it("should apply providerOptions type override", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel( + "text-embedding-ada-002", + { type: "text" }, + defaultConfig, + ); + + await model.doEmbed({ providerOptions: { "sap-ai": { type: "query" } }, values: ["Test"] }); + + expect(MockOrchestrationEmbeddingClient.lastEmbedCall?.request.type).toBe("query"); + }); + + it("should apply providerOptions modelParams override", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel( + "text-embedding-3-large", + { modelParams: { dimensions: 256 } }, + defaultConfig, + ); + + await model.doEmbed({ + providerOptions: { "sap-ai": { modelParams: { dimensions: 1024 } } }, + values: ["Test"], + }); + + expect( + MockOrchestrationEmbeddingClient.lastConstructorCall?.config.embeddings.model.params, + ).toEqual({ + dimensions: 1024, + }); + }); + + it("should merge per-call modelParams with constructor modelParams", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + const model = new SAPAIEmbeddingModel( + "text-embedding-3-large", + { modelParams: { customParam: "from-constructor", dimensions: 256 } }, + defaultConfig, + ); + + await model.doEmbed({ + providerOptions: { "sap-ai": { modelParams: { dimensions: 1024 } } }, + values: ["Test"], + }); + + expect(MockOrchestrationEmbeddingClient.lastConstructorCall?.config.embeddings.model).toEqual( + { + name: "text-embedding-3-large", + params: { customParam: "from-constructor", dimensions: 1024 }, + }, + ); + }); + }); + + describe("error handling", () => { + it("should convert SAP errors to AI SDK errors", async () => { + const { MockOrchestrationEmbeddingClient } = await getMockClient(); + MockOrchestrationEmbeddingClient.embedError = new Error("SAP API Error"); + const model = new SAPAIEmbeddingModel("text-embedding-ada-002", {}, defaultConfig); + + await expect(model.doEmbed({ values: ["Test"] })).rejects.toThrow(); + }); + }); +}); diff --git a/src/sap-ai-embedding-model.ts b/src/sap-ai-embedding-model.ts new file mode 100644 index 0000000..77ba136 --- /dev/null +++ b/src/sap-ai-embedding-model.ts @@ -0,0 +1,227 @@ +/** + * SAP AI Embedding Model - Vercel AI SDK EmbeddingModelV3 implementation for SAP AI Core. + * + * This is the main implementation containing all business logic for SAP AI Core embedding generation. + * @module sap-ai-embedding-model + */ + +import type { + EmbeddingModelV3, + EmbeddingModelV3CallOptions, + EmbeddingModelV3Embedding, + EmbeddingModelV3Result, + SharedV3ProviderMetadata, +} from "@ai-sdk/provider"; +import type { DeploymentIdConfig, ResourceGroupConfig } from "@sap-ai-sdk/ai-api/internal.js"; +import type { EmbeddingModelConfig, EmbeddingModelParams } from "@sap-ai-sdk/orchestration"; +import type { HttpDestinationOrFetchOptions } from "@sap-cloud-sdk/connectivity"; + +import { TooManyEmbeddingValuesForCallError } from "@ai-sdk/provider"; +import { parseProviderOptions } from "@ai-sdk/provider-utils"; +import { OrchestrationEmbeddingClient } from "@sap-ai-sdk/orchestration"; + +import { deepMerge } from "./deep-merge.js"; +import { convertToAISDKError } from "./sap-ai-error.js"; +import { + getProviderName, + sapAIEmbeddingProviderOptions, + validateEmbeddingModelParamsSettings, +} from "./sap-ai-provider-options.js"; +import { VERSION } from "./version.js"; + +/** Default maximum embeddings per API call (OpenAI limit). */ +const DEFAULT_MAX_EMBEDDINGS_PER_CALL = 2048; + +/** Model identifier for SAP AI embedding models (e.g., 'text-embedding-ada-002'). */ +export type SAPAIEmbeddingModelId = string; + +/** + * Settings for the SAP AI Embedding Model. + */ +export interface SAPAIEmbeddingSettings { + /** + * Maximum number of embeddings per API call. + * @default 2048 + */ + readonly maxEmbeddingsPerCall?: number; + + /** + * Additional model parameters passed to the embedding API. + */ + readonly modelParams?: EmbeddingModelParams; + + /** + * Embedding task type. + * @default 'text' + */ + readonly type?: "document" | "query" | "text"; +} + +/** + * Internal configuration for SAP AI Embedding Model. + * @internal + */ +interface SAPAIEmbeddingModelConfig { + readonly deploymentConfig: DeploymentIdConfig | ResourceGroupConfig; + readonly destination?: HttpDestinationOrFetchOptions; + readonly provider: string; +} + +/** + * SAP AI Core Embedding Model implementing Vercel AI SDK EmbeddingModelV3. + * @example + * ```typescript + * const { embedding } = await embed({ + * model: provider.embedding('text-embedding-ada-002'), + * value: 'Hello, world!' + * }); + * ``` + */ +export class SAPAIEmbeddingModel implements EmbeddingModelV3 { + /** Maximum number of embeddings per API call. */ + readonly maxEmbeddingsPerCall: number; + /** The model identifier. */ + readonly modelId: string; + /** The provider identifier string. */ + readonly provider: string; + /** The Vercel AI SDK specification version. */ + readonly specificationVersion = "v3" as const; + /** Whether the model supports parallel API calls. */ + readonly supportsParallelCalls: boolean = true; + + private readonly config: SAPAIEmbeddingModelConfig; + private readonly settings: SAPAIEmbeddingSettings; + + /** + * Creates a new SAP AI Embedding Model instance. + * + * This is the main implementation that handles all SAP AI Core embedding logic. + * @param modelId - The model identifier (e.g., 'text-embedding-ada-002', 'text-embedding-3-small'). + * @param settings - Model configuration settings (embedding type, model parameters, etc.). + * @param config - SAP AI Core deployment and destination configuration. + */ + constructor( + modelId: SAPAIEmbeddingModelId, + settings: SAPAIEmbeddingSettings = {}, + config: SAPAIEmbeddingModelConfig, + ) { + if (settings.modelParams) { + validateEmbeddingModelParamsSettings(settings.modelParams); + } + this.modelId = modelId; + this.settings = settings; + this.config = config; + this.provider = config.provider; + this.maxEmbeddingsPerCall = settings.maxEmbeddingsPerCall ?? DEFAULT_MAX_EMBEDDINGS_PER_CALL; + } + + /** + * Generates embeddings for the given input values. + * + * Validates input count, merges settings, calls SAP AI SDK, and normalizes embeddings. + * @param options - The Vercel AI SDK V3 embedding call options. + * @returns The embedding result with vectors, usage data, and warnings. + */ + async doEmbed(options: EmbeddingModelV3CallOptions): Promise { + const { abortSignal, providerOptions, values } = options; + + const providerName = getProviderName(this.config.provider); + const sapOptions = await parseProviderOptions({ + provider: providerName, + providerOptions, + schema: sapAIEmbeddingProviderOptions, + }); + + if (values.length > this.maxEmbeddingsPerCall) { + throw new TooManyEmbeddingValuesForCallError({ + maxEmbeddingsPerCall: this.maxEmbeddingsPerCall, + modelId: this.modelId, + provider: this.provider, + values, + }); + } + + const embeddingType = sapOptions?.type ?? this.settings.type ?? "text"; + + try { + const client = this.createClient(sapOptions?.modelParams); + + const response = await client.embed( + { input: values, type: embeddingType }, + abortSignal ? { signal: abortSignal } : undefined, + ); + + const embeddingData = response.getEmbeddings(); + const tokenUsage = response.getTokenUsage(); + const sortedEmbeddings = [...embeddingData].sort((a, b) => a.index - b.index); + + const embeddings: EmbeddingModelV3Embedding[] = sortedEmbeddings.map((data) => + this.normalizeEmbedding(data.embedding), + ); + + const providerMetadata: SharedV3ProviderMetadata = { + [providerName]: { + model: this.modelId, + version: VERSION, + }, + }; + + return { + embeddings, + providerMetadata, + usage: { tokens: tokenUsage.total_tokens }, + warnings: [], + }; + } catch (error) { + throw convertToAISDKError(error, { + operation: "doEmbed", + requestBody: { values: values.length }, + url: "sap-ai:orchestration/embeddings", + }); + } + } + + /** + * Creates an SAP AI SDK OrchestrationEmbeddingClient with merged configuration. + * @param perCallModelParams - Per-call model parameters to merge with instance settings. + * @returns A configured SAP AI SDK embedding client instance. + * @internal + */ + private createClient(perCallModelParams?: Record): OrchestrationEmbeddingClient { + const mergedParams = deepMerge(this.settings.modelParams ?? {}, perCallModelParams ?? {}); + const hasParams = Object.keys(mergedParams).length > 0; + + const embeddingConfig: EmbeddingModelConfig = { + model: { + name: this.modelId, + ...(hasParams ? { params: mergedParams } : {}), + }, + }; + + return new OrchestrationEmbeddingClient( + { embeddings: embeddingConfig }, + this.config.deploymentConfig, + this.config.destination, + ); + } + + /** + * Converts SAP AI SDK embedding (number[] or base64) to Vercel AI SDK format. + * @param embedding - The embedding as number array or base64 string. + * @returns The normalized embedding as a number array. + * @internal + */ + private normalizeEmbedding(embedding: number[] | string): EmbeddingModelV3Embedding { + if (Array.isArray(embedding)) { + return embedding; + } + // Base64-encoded float32 values + const buffer = Buffer.from(embedding, "base64"); + const float32Array = new Float32Array( + buffer.buffer, + buffer.byteOffset, + buffer.length / Float32Array.BYTES_PER_ELEMENT, + ); + return Array.from(float32Array); + } +} diff --git a/src/sap-ai-error.test.ts b/src/sap-ai-error.test.ts new file mode 100644 index 0000000..85fd27f --- /dev/null +++ b/src/sap-ai-error.test.ts @@ -0,0 +1,901 @@ +/** + * Tests for SAP AI Core error conversion to AI SDK error types. + */ +import type { OrchestrationErrorResponse } from "@sap-ai-sdk/orchestration"; + +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +import { describe, expect, it } from "vitest"; + +import { convertSAPErrorToAPICallError, convertToAISDKError } from "./sap-ai-error"; + +interface ParsedResponseBody { + error: { + code?: number; + location?: string; + message?: string; + request_id?: string; + }; +} + +describe("convertSAPErrorToAPICallError", () => { + describe("basic conversion", () => { + it("should convert SAP error with single error object", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code: 500, + location: "LLM Module", + message: "Test error message", + request_id: "test-request-123", + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError) { + expect(result.statusCode).toBe(500); + expect(result.isRetryable).toBe(true); + } + expect(result.message).toContain("Test error message"); + }); + + it("should convert SAP error with error list (array)", () => { + const errorResponse: OrchestrationErrorResponse = { + error: [ + { + code: 400, + location: "Input Module", + message: "First error", + request_id: "test-request-456", + }, + ], + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError) { + expect(result.statusCode).toBe(400); + } + expect(result.message).toContain("First error"); + }); + + it("should handle error list with multiple entries (uses first)", () => { + const errorResponse: OrchestrationErrorResponse = { + error: [ + { + code: 400, + location: "First Module", + message: "First error in list", + request_id: "first-123", + }, + { + code: 500, + location: "Second Module", + message: "Second error in list", + request_id: "second-456", + }, + ], + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result.message).toContain("First error in list"); + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError) { + expect(result.statusCode).toBe(400); + } + }); + }); + + describe("retryable status codes", () => { + it.each([ + { code: 408, description: "Request Timeout" }, + { code: 409, description: "Conflict" }, + { code: 429, description: "Rate Limit" }, + { code: 500, description: "Internal Server Error" }, + { code: 502, description: "Bad Gateway" }, + { code: 503, description: "Service Unavailable" }, + { code: 504, description: "Gateway Timeout" }, + ])("should mark $code ($description) errors as retryable", ({ code }) => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code, + location: "Gateway", + message: `Error ${String(code)}`, + request_id: `error-${String(code)}`, + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError) { + expect(result.statusCode).toBe(code); + expect(result.isRetryable).toBe(true); + } + }); + }); + + describe("authentication errors", () => { + it.each([ + { code: 401, description: "Unauthorized" }, + { code: 403, description: "Forbidden" }, + ])("should convert $code ($description) errors to LoadAPIKeyError", ({ code }) => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code, + location: "Auth", + message: `${String(code)} error`, + request_id: `error-${String(code)}`, + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result).toBeInstanceOf(LoadAPIKeyError); + expect(result.message).toContain("Authentication failed"); + expect(result.message).toContain("AICORE_SERVICE_KEY"); + }); + }); + + describe("not found errors", () => { + it("should convert 404 errors to NoSuchModelError", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code: 404, + location: "Deployment", + message: "Model deployment-abc-123 not found", + request_id: "error-404", + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result).toBeInstanceOf(NoSuchModelError); + expect(result.message).toContain("Resource not found"); + if (result instanceof NoSuchModelError) { + expect(result.modelId).toBe("deployment-abc-123"); + expect(result.modelType).toBe("languageModel"); + } + }); + }); + + describe("context handling", () => { + it("should preserve SAP metadata in responseBody", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code: 500, + location: "Test Module", + message: "Test error", + request_id: "metadata-test-123", + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError && result.responseBody) { + const body = JSON.parse(result.responseBody) as ParsedResponseBody; + expect(body.error.message).toBe("Test error"); + expect(body.error.code).toBe(500); + expect(body.error.location).toBe("Test Module"); + expect(body.error.request_id).toBe("metadata-test-123"); + } + }); + + it("should add context URL, headers, and requestBody to error", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { code: 500, location: "Module", message: "Test error", request_id: "context-test" }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse, { + requestBody: { prompt: "test" }, + responseHeaders: { "x-request-id": "test-123" }, + url: "https://api.sap.com/v1/chat", + }); + + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError) { + expect(result.url).toBe("https://api.sap.com/v1/chat"); + expect(result.responseHeaders).toEqual({ "x-request-id": "test-123" }); + expect(result.requestBodyValues).toEqual({ prompt: "test" }); + } + }); + + it("should add request ID to error message", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code: 500, + location: "Module", + message: "Test error", + request_id: "message-test-123", + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result.message).toContain("Request ID: message-test-123"); + }); + }); + + describe("missing fields handling", () => { + it.each([ + { + errorResponse: { error: { message: "Unknown error", request_id: "unknown-123" } }, + expectedRetryable: true, + expectedStatus: 500, + field: "code", + }, + { + errorResponse: { error: { code: 400, message: "Error without location" } }, + expectedStatus: 400, + field: "location", + notContains: "Error location:", + }, + { + errorResponse: { error: { code: 400, message: "Error without request ID" } }, + expectedStatus: 400, + field: "request_id", + notContains: "Request ID:", + }, + ])( + "should handle error without $field", + ({ errorResponse, expectedRetryable, expectedStatus, notContains }) => { + const result = convertSAPErrorToAPICallError( + errorResponse as unknown as OrchestrationErrorResponse, + ); + + expect(result).toBeInstanceOf(APICallError); + if (result instanceof APICallError) { + expect(result.statusCode).toBe(expectedStatus); + if (expectedRetryable !== undefined) { + expect(result.isRetryable).toBe(expectedRetryable); + } + } + if (notContains) { + expect(result.message).not.toContain(notContains); + } + }, + ); + + it("should include location in error message for 4xx errors", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code: 400, + location: "Input Validation", + message: "Bad request", + request_id: "validation-123", + }, + }; + + const result = convertSAPErrorToAPICallError(errorResponse); + + expect(result.message).toContain("Error location: Input Validation"); + }); + }); +}); + +describe("convertToAISDKError", () => { + describe("passthrough", () => { + it.each([ + { + error: new APICallError({ + message: "Test", + requestBodyValues: {}, + statusCode: 500, + url: "https://test.com", + }), + type: "APICallError", + }, + { error: new LoadAPIKeyError({ message: "API key error" }), type: "LoadAPIKeyError" }, + { + error: new NoSuchModelError({ + message: "No model", + modelId: "test", + modelType: "languageModel", + }), + type: "NoSuchModelError", + }, + ])("should return $type as-is", ({ error }) => { + const result = convertToAISDKError(error); + expect(result).toBe(error); + }); + }); + + describe("orchestration error conversion", () => { + it("should convert OrchestrationErrorResponse", () => { + const errorResponse: OrchestrationErrorResponse = { + error: { + code: 500, + location: "Module", + message: "Orchestration error", + request_id: "conversion-test-123", + }, + }; + + const result = convertToAISDKError(errorResponse); + + expect(result).toBeInstanceOf(APICallError); + expect(result.message).toContain("Orchestration error"); + }); + + it.each([ + { desc: "non-string message", errorObject: { error: { message: 123 } } }, + { desc: "array with non-object entries", errorObject: { error: ["not an object"] } }, + { desc: "array with null entries", errorObject: { error: [null, { message: "valid" }] } }, + { desc: "array with entries missing message", errorObject: { error: [{ code: 400 }] } }, + { + desc: "array with non-string message", + errorObject: { error: [{ message: { nested: "object" } }] }, + }, + { desc: "undefined error property", errorObject: { error: undefined } }, + ])("should not treat $desc as orchestration errors", ({ errorObject }) => { + const result = convertToAISDKError(errorObject); + + expect(result).toBeInstanceOf(APICallError); + expect((result as APICallError).statusCode).toBe(500); + }); + }); + + describe("authentication error detection", () => { + it.each([ + "Authentication failed for AICORE_SERVICE_KEY", + "Request unauthorized", + "Invalid credentials provided", + "Service credentials not found", + "Service binding error", + ])("should convert '%s' to LoadAPIKeyError", (message) => { + const result = convertToAISDKError(new Error(message)); + + expect(result).toBeInstanceOf(LoadAPIKeyError); + expect(result.message).toContain("SAP AI Core authentication failed"); + }); + }); + + describe("network error detection", () => { + it.each([ + { desc: "ECONNREFUSED", message: "ECONNREFUSED: Connection refused" }, + { desc: "ENOTFOUND", message: "getaddrinfo ENOTFOUND api.sap.com" }, + { desc: "network", message: "NETWORK connection failed" }, + { desc: "timeout", message: "Request timeout exceeded" }, + ])("should convert $desc errors to retryable APICallError", ({ message }) => { + const result = convertToAISDKError(new Error(message)); + + expect(result).toBeInstanceOf(APICallError); + expect((result as APICallError).isRetryable).toBe(true); + expect((result as APICallError).statusCode).toBe(503); + }); + + it("should include response body for network errors when available", () => { + const axiosError = new Error("Network timeout"); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: { + code: 503, + message: "Service temporarily unavailable", + }, + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(503); + expect(result.responseBody).toBeDefined(); + expect(result.message).toContain("SAP AI Core Error Response:"); + expect(result.message).toContain("Service temporarily unavailable"); + }); + }); + + describe("generic error handling", () => { + it("should convert generic errors to non-retryable APICallError", () => { + const result = convertToAISDKError(new Error("Something went wrong")); + + expect(result).toBeInstanceOf(APICallError); + expect((result as APICallError).isRetryable).toBe(false); + expect((result as APICallError).statusCode).toBe(500); + }); + + it.each([ + { desc: "string", value: "An error occurred" }, + { desc: "null", value: null }, + { desc: "undefined", value: undefined }, + { desc: "number", value: 42 }, + { desc: "unknown object", value: { weird: "object" } }, + ])("should handle $desc error values", ({ value }) => { + const result = convertToAISDKError(value); + + expect(result).toBeInstanceOf(APICallError); + if (typeof value === "string") { + expect(result.message).toContain(value); + } else { + expect(result.message).toContain("Unknown error occurred"); + } + }); + }); + + describe("context handling", () => { + it("should add operation context to error message", () => { + const result = convertToAISDKError(new Error("Test error"), { operation: "doGenerate" }); + expect(result.message).toContain("doGenerate"); + }); + + it("should handle error without operation context", () => { + const result = convertToAISDKError(new Error("Simple error")); + expect(result.message).toContain("SAP AI Core error:"); + expect(result.message).not.toContain("undefined"); + }); + + it("should pass through context URL and requestBody", () => { + const result = convertToAISDKError(new Error("Test"), { + operation: "doStream", + requestBody: { test: "data" }, + url: "https://api.sap.com", + }) as APICallError; + + expect(result.url).toBe("https://api.sap.com"); + expect(result.requestBodyValues).toEqual({ test: "data" }); + }); + + it("should preserve response headers from context", () => { + const result = convertToAISDKError(new Error("Request failed"), { + responseHeaders: { "x-request-id": "axios-123" }, + }) as APICallError; + + expect(result.responseHeaders).toEqual({ "x-request-id": "axios-123" }); + }); + }); + + describe("axios header normalization", () => { + const createAxiosError = (headers: Record) => { + const err = new Error("Request failed") as unknown as { + isAxiosError: boolean; + response: { headers: Record }; + }; + err.isAxiosError = true; + err.response = { headers }; + return err; + }; + + it.each([ + { + desc: "array values joined with semicolon", + expected: { "x-multi": "a; b; c" }, + headers: { "x-multi": ["a", "b", "c"] }, + }, + { + desc: "non-string values filtered from arrays", + expected: { "x-mixed": "valid; also" }, + headers: { "x-mixed": ["valid", 123, null, "also"] }, + }, + { + desc: "arrays with only non-strings excluded", + expected: { "x-valid": "keep" }, + headers: { "x-invalid": [123, null], "x-valid": "keep" }, + }, + { + desc: "number values converted to strings", + expected: { "content-length": "1024" }, + headers: { "content-length": 1024 }, + }, + { + desc: "boolean values converted to strings", + expected: { "x-disabled": "false", "x-enabled": "true" }, + headers: { "x-disabled": false, "x-enabled": true }, + }, + { + desc: "object values skipped", + expected: { "x-valid": "keep" }, + headers: { "x-object": { nested: "obj" }, "x-valid": "keep" }, + }, + ])("should handle $desc", ({ expected, headers }) => { + const result = convertToAISDKError(createAxiosError(headers)) as APICallError; + expect(result.responseHeaders).toEqual(expected); + }); + + it.each([ + { desc: "all unsupported types", headers: { "x-object": { nested: "object" } } }, + { desc: "null headers", headers: null }, + ])("should return undefined for $desc", ({ headers }) => { + const err = new Error("Request failed") as unknown as { + isAxiosError: boolean; + response: { headers: unknown }; + }; + err.isAxiosError = true; + err.response = { headers }; + + const result = convertToAISDKError(err) as APICallError; + expect(result.responseHeaders).toBeUndefined(); + }); + + it("should return undefined when rootCause is not an object", () => { + const result = convertToAISDKError("just a string error") as APICallError; + expect(result.responseHeaders).toBeUndefined(); + }); + }); +}); + +describe("sse error handling", () => { + it("should extract SAP error from SSE message (wrapped format)", () => { + const sapError = { + error: { + code: 429, + location: "Rate Limiter", + message: "Too many requests", + request_id: "sse-error-123", + }, + }; + const error = new Error(`Error received from the server.\\n${JSON.stringify(sapError)}`); + + const result = convertToAISDKError(error) as APICallError; + + expect(result).toBeInstanceOf(APICallError); + expect(result.statusCode).toBe(429); + expect(result.message).toContain("Too many requests"); + expect(result.isRetryable).toBe(true); + const responseBody = JSON.parse(result.responseBody ?? "{}") as ParsedResponseBody; + expect(responseBody.error.request_id).toBe("sse-error-123"); + }); + + it("should extract SAP error from SSE message (direct format)", () => { + const sapErrorDirect = { + code: 503, + message: "Service unavailable", + request_id: "sse-direct-123", + }; + const error = new Error(`Error received from the server.\\n${JSON.stringify(sapErrorDirect)}`); + + const result = convertToAISDKError(error) as APICallError; + + expect(result.statusCode).toBe(503); + expect(result.isRetryable).toBe(true); + }); + + it("should extract SAP error from ErrorWithCause rootCause", () => { + const sapError = { + error: { code: 500, message: "Model overloaded", request_id: "wrapped-123" }, + }; + const innerError = new Error(`Error received from the server.\n${JSON.stringify(sapError)}`); + const wrappedError = new Error("Error while iterating over SSE stream."); + Object.defineProperty(wrappedError, "name", { value: "ErrorWithCause" }); + Object.defineProperty(wrappedError, "rootCause", { get: () => innerError }); + + const result = convertToAISDKError(wrappedError) as APICallError; + + expect(result.statusCode).toBe(500); + const responseBody = JSON.parse(result.responseBody ?? "{}") as ParsedResponseBody; + expect(responseBody.error.request_id).toBe("wrapped-123"); + }); + + it.each([ + { + contains: "stream consumption", + desc: "stream iteration", + message: "Cannot iterate over a consumed stream.", + retryable: false, + }, + { + contains: "streaming error", + desc: "message parsing", + message: "Could not parse message into JSON", + retryable: true, + }, + { + contains: "streaming error", + desc: "no body", + message: "Attempted to iterate over a response with no body", + retryable: true, + }, + { + desc: "malformed JSON", + message: "Error received from the server.\n{invalid json}", + status: 500, + }, + ])("should handle $desc errors", ({ contains, message, retryable, status }) => { + const result = convertToAISDKError(new Error(message)) as APICallError; + + expect(result).toBeInstanceOf(APICallError); + if (contains) expect(result.message).toContain(contains); + if (retryable !== undefined) expect(result.isRetryable).toBe(retryable); + if (status) expect(result.statusCode).toBe(status); + }); + + it("should handle streaming errors with wrapped parsing failures", () => { + const innerError = new Error("Could not parse message into JSON"); + const wrappedError = new Error("Error while iterating over SSE stream."); + Object.defineProperty(wrappedError, "name", { value: "ErrorWithCause" }); + Object.defineProperty(wrappedError, "rootCause", { get: () => innerError }); + + const result = convertToAISDKError(wrappedError) as APICallError; + + expect(result.message).toContain("streaming error"); + expect(result.isRetryable).toBe(true); + }); + + it("should traverse nested ErrorWithCause chain", () => { + const rootError = new Error("Network timeout"); + const topError = new Error("SSE stream error"); + Object.defineProperty(topError, "name", { value: "ErrorWithCause" }); + Object.defineProperty(topError, "rootCause", { get: () => rootError }); + + const result = convertToAISDKError(topError) as APICallError; + + expect(result.message).toContain("Network timeout"); + }); + + it("should handle server errors received during streaming", () => { + const serverError = { code: 429, message: "Rate limited", request_id: "test-123" }; + const error = new Error(`Error received from the server.\n${JSON.stringify(serverError)}`); + + const result = convertToAISDKError(error) as APICallError; + + expect(result.statusCode).toBe(429); + expect(result.isRetryable).toBe(true); + }); +}); + +describe("sdk-specific error handling", () => { + describe("destination and deployment errors", () => { + it("should handle destination resolution errors", () => { + const result = convertToAISDKError( + new Error("Could not resolve destination."), + ) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.isRetryable).toBe(false); + expect(result.message).toContain("destination"); + }); + + it("should handle deployment resolution errors", () => { + const result = convertToAISDKError(new Error("Failed to resolve deployment: d123abc")); + + expect(result).toBeInstanceOf(NoSuchModelError); + if (result instanceof NoSuchModelError) { + expect(result.modelId).toBe("d123abc"); + expect(result.modelType).toBe("languageModel"); + } + }); + + it("should handle ErrorWithCause chain with network error as root", () => { + const networkError = new Error("getaddrinfo ENOTFOUND api.ai.sap.com"); + const outerError = new Error("Failed to fetch deployments"); + Object.defineProperty(outerError, "name", { value: "ErrorWithCause" }); + Object.defineProperty(outerError, "rootCause", { get: () => networkError }); + + const result = convertToAISDKError(outerError) as APICallError; + + expect(result.statusCode).toBe(503); + expect(result.isRetryable).toBe(true); + }); + + it("should include response body for destination errors", () => { + const axiosError = new Error("Could not resolve destination"); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: { + error: "DESTINATION_NOT_FOUND", + message: "Destination 'my-dest' does not exist", + }, + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.responseBody).toBeDefined(); + expect(result.message).toContain("SAP AI Core Error Response:"); + expect(result.message).toContain("DESTINATION_NOT_FOUND"); + }); + }); + + describe("content and configuration errors (non-retryable 400)", () => { + it.each([ + "Content was filtered by the output filter.", + "Either a prompt template or messages must be defined.", + "Filtering parameters cannot be empty", + "Could not access response data. Response was not an axios response.", + "Could not parse JSON: invalid syntax", + "Error parsing YAML: unexpected token", + "Prompt Template YAML does not conform to the defined type. Validation errors: missing required field", + "Templating YAML string must be non-empty.", + ])("should handle '%s' as non-retryable 400", (message) => { + const result = convertToAISDKError(new Error(message)) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.isRetryable).toBe(false); + }); + + it("should include response body for configuration errors", () => { + const axiosError = new Error("Filtering parameters cannot be empty"); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: { + code: 400, + details: "At least one filter must be specified", + message: "Invalid filter configuration", + }, + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.responseBody).toBeDefined(); + expect(result.message).toContain("SAP AI Core Error Response:"); + expect(result.message).toContain("Invalid filter configuration"); + }); + }); + + describe("server errors", () => { + it.each([ + { + message: "Response is required to process completion post response streaming.", + retryable: true, + }, + { message: "Response is required to process stream end.", retryable: true }, + { + message: "The stream is still open, the requested data is not available yet.", + retryable: true, + }, + { message: "Response stream is undefined.", retryable: false }, + { message: "Unexpected: Buffer is not available as globals.", retryable: false }, + { + message: "Unexpected: received non-Uint8Array (ArrayBuffer) stream chunk", + retryable: false, + }, + ])("should handle '$message' with retryable=$retryable", ({ message, retryable }) => { + const result = convertToAISDKError(new Error(message)) as APICallError; + + expect(result.statusCode).toBe(500); + expect(result.isRetryable).toBe(retryable); + }); + + it("should handle deployment list fetch error as retryable 503", () => { + const result = convertToAISDKError( + new Error("Failed to fetch the list of deployments."), + ) as APICallError; + + expect(result.statusCode).toBe(503); + expect(result.isRetryable).toBe(true); + }); + + it("should handle invalid SSE payload errors as retryable 500", () => { + const result = convertToAISDKError( + new Error("Invalid SSE payload: malformed event data"), + ) as APICallError; + + expect(result.statusCode).toBe(500); + expect(result.isRetryable).toBe(true); + }); + }); + + describe("status code extraction", () => { + it("should extract status code from error message", () => { + const result = convertToAISDKError( + new Error("Request failed with status code 429."), + ) as APICallError; + + expect(result.statusCode).toBe(429); + expect(result.isRetryable).toBe(true); + }); + + it("should extract and format axios error response body", () => { + const axiosError = new Error("Request failed with status code 400."); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: { + code: 400, + location: "Input Parameters", + message: + "400 - Input Parameters: Error validating parameters. Unused parameters: ['question'].", + request_id: "258f5390-51f6-93cc-a066-858be2558a64", + }, + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.responseBody).toBeDefined(); + expect(result.message).toContain("SAP AI Core Error Response:"); + expect(result.message).toContain("request_id"); + expect(result.message).toContain("258f5390-51f6-93cc-a066-858be2558a64"); + }); + + it("should handle axios error with string response data", () => { + const axiosError = new Error("Request failed with status code 500."); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: "Internal Server Error", + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(500); + expect(result.responseBody).toBe("Internal Server Error"); + expect(result.message).toContain("SAP AI Core Error Response:"); + expect(result.message).toContain("Internal Server Error"); + }); + + it("should truncate large response bodies", () => { + const largeData = { error: "x".repeat(3000) }; + const axiosError = new Error("Request failed with status code 400."); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: largeData, + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.responseBody).toBeDefined(); + if (result.responseBody) { + expect(result.responseBody.length).toBeLessThanOrEqual(2014); // 2000 + "...[truncated]" + } + expect(result.responseBody).toContain("...[truncated]"); + expect(result.message).toContain("...[truncated]"); + }); + + it("should handle JSON.stringify errors gracefully", () => { + const circularData: { a: number; self?: unknown } = { a: 1 }; + circularData.self = circularData; // Create circular reference + + const axiosError = new Error("Request failed with status code 400."); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: circularData, + }, + }); + + const result = convertToAISDKError(axiosError) as APICallError; + + expect(result.statusCode).toBe(400); + expect(result.responseBody).toBeDefined(); + expect(result.message).toContain("SAP AI Core Error Response:"); + // Should fall back to type indication for circular references + expect(result.responseBody).toContain("[Unable to serialize: object]"); + }); + + it("should extract axios error nested in ErrorWithCause", () => { + const axiosError = new Error("Request failed with status code 401."); + Object.assign(axiosError, { + isAxiosError: true, + response: { + data: { + code: 401, + message: "Unauthorized", + }, + }, + }); + + const outerError = new Error("Request failed with status code 401."); + Object.defineProperty(outerError, "name", { value: "ErrorWithCause" }); + Object.defineProperty(outerError, "rootCause", { get: () => axiosError }); + + const result = convertToAISDKError(outerError) as APICallError; + + expect(result.statusCode).toBe(401); + expect(result.responseBody).toBeDefined(); + expect(result.message).toContain("Unauthorized"); + }); + + it("should handle errors without axios response body", () => { + const simpleError = new Error("Network timeout"); + + const result = convertToAISDKError(simpleError) as APICallError; + + expect(result.statusCode).toBe(503); + expect(result.responseBody).toBeUndefined(); + expect(result.message).not.toContain("SAP AI Core Error Response:"); + }); + }); +}); diff --git a/src/sap-ai-error.ts b/src/sap-ai-error.ts index d5fe096..f340ed0 100644 --- a/src/sap-ai-error.ts +++ b/src/sap-ai-error.ts @@ -1,137 +1,698 @@ +/** + * Error conversion utilities for SAP AI Core to Vercel AI SDK error types. + */ import type { OrchestrationErrorResponse } from "@sap-ai-sdk/orchestration"; +import { APICallError, LoadAPIKeyError, NoSuchModelError } from "@ai-sdk/provider"; +import { isErrorWithCause } from "@sap-cloud-sdk/util"; + /** - * Custom error class for SAP AI Core errors. - * Provides structured access to error details returned by the API. - * - * The SAP AI SDK handles most error responses internally, but this class - * can be used to wrap and provide additional context for errors. - * - * @example - * ```typescript - * try { - * await model.doGenerate({ prompt }); - * } catch (error) { - * if (error instanceof SAPAIError) { - * console.error('Error Code:', error.code); - * console.error('Request ID:', error.requestId); - * console.error('Location:', error.location); - * } - * } - * ``` + * @internal */ -export class SAPAIError extends Error { - /** HTTP status code or custom error code */ - public readonly code?: number; - - /** Where the error occurred (e.g., module name) */ - public readonly location?: string; - - /** Unique identifier for tracking the request */ - public readonly requestId?: string; - - /** Additional error context or debugging information */ - public readonly details?: string; - - /** Original cause of the error */ - public readonly cause?: unknown; - - constructor( - message: string, - options?: { - code?: number; - location?: string; - requestId?: string; - details?: string; - cause?: unknown; +const HTTP_STATUS = { + BAD_REQUEST: 400, + CONFLICT: 409, + FORBIDDEN: 403, + INTERNAL_ERROR: 500, + NOT_FOUND: 404, + RATE_LIMIT: 429, + REQUEST_TIMEOUT: 408, + SERVICE_UNAVAILABLE: 503, + UNAUTHORIZED: 401, +} as const; + +/** + * Converts SAP AI SDK OrchestrationErrorResponse to Vercel AI SDK APICallError. + * @param errorResponse - The error response from SAP AI SDK. + * @param context - Optional context for error details. + * @param context.requestBody - The original request body. + * @param context.responseHeaders - The response headers. + * @param context.url - The request URL. + * @returns An appropriate Vercel AI SDK error type. + */ +export function convertSAPErrorToAPICallError( + errorResponse: OrchestrationErrorResponse, + context?: { + requestBody?: unknown; + responseHeaders?: Record; + url?: string; + }, +): APICallError | LoadAPIKeyError | NoSuchModelError { + const error = errorResponse.error; + + let message: string; + let code: number | undefined; + let location: string | undefined; + let requestId: string | undefined; + + if (Array.isArray(error)) { + const firstError = error[0]; + if (firstError) { + message = firstError.message; + code = firstError.code; + location = firstError.location; + requestId = firstError.request_id; + } else { + message = "Unknown SAP AI error"; + } + } else { + message = error.message; + code = error.code; + location = error.location; + requestId = error.request_id; + } + + const statusCode = getStatusCodeFromSAPError(code); + + const responseBody = JSON.stringify({ + error: { + code, + location, + message, + request_id: requestId, }, + }); + + let enhancedMessage = message; + + if (statusCode === HTTP_STATUS.UNAUTHORIZED || statusCode === HTTP_STATUS.FORBIDDEN) { + enhancedMessage += + "\n\nAuthentication failed. Verify your AICORE_SERVICE_KEY environment variable is set correctly." + + "\nSee: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/create-service-key"; + if (requestId) { + enhancedMessage += `\nRequest ID: ${requestId}`; + } + return new LoadAPIKeyError({ + message: enhancedMessage, + }); + } + + if (statusCode === HTTP_STATUS.NOT_FOUND) { + enhancedMessage += + "\n\nResource not found. The model or deployment may not exist in your SAP AI Core instance." + + "\nSee: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/create-deployment-for-orchestration"; + if (requestId) { + enhancedMessage += `\nRequest ID: ${requestId}`; + } + const modelId = extractModelIdentifier(message, location); + return new NoSuchModelError({ + message: enhancedMessage, + modelId: modelId ?? "unknown", + modelType: "languageModel", + }); + } + + if (statusCode === HTTP_STATUS.RATE_LIMIT) { + enhancedMessage += + "\n\nRate limit exceeded. Please try again later or contact your SAP administrator." + + "\nSee: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/rate-limits"; + } else if (statusCode >= HTTP_STATUS.INTERNAL_ERROR) { + enhancedMessage += + "\n\nSAP AI Core service error. This is typically a temporary issue. The request will be retried automatically." + + "\nSee: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/troubleshooting"; + } else if (location) { + enhancedMessage += `\n\nError location: ${location}`; + } + + if (requestId) { + enhancedMessage += `\nRequest ID: ${requestId}`; + } + + return new APICallError({ + isRetryable: isRetryable(statusCode), + message: enhancedMessage, + requestBodyValues: context?.requestBody, + responseBody, + responseHeaders: context?.responseHeaders, + statusCode, + url: context?.url ?? "", + }); +} + +/** + * Converts a generic error to an appropriate Vercel AI SDK error. + * @param error - The error to convert. + * @param context - Optional context for error details. + * @param context.operation - The operation name for error messages. + * @param context.requestBody - The original request body. + * @param context.responseHeaders - The response headers. + * @param context.url - The request URL. + * @returns An appropriate Vercel AI SDK error type. + */ +export function convertToAISDKError( + error: unknown, + context?: { + operation?: string; + requestBody?: unknown; + responseHeaders?: Record; + url?: string; + }, +): APICallError | LoadAPIKeyError | NoSuchModelError { + if ( + error instanceof APICallError || + error instanceof LoadAPIKeyError || + error instanceof NoSuchModelError ) { - super(message); - this.name = "SAPAIError"; - this.code = options?.code; - this.location = options?.location; - this.requestId = options?.requestId; - this.details = options?.details; - this.cause = options?.cause; - } - - /** - * Creates a SAPAIError from an OrchestrationErrorResponse. - * - * @param errorResponse - The error response from SAP AI SDK - * @returns A new SAPAIError instance - */ - static fromOrchestrationError( - errorResponse: OrchestrationErrorResponse, - ): SAPAIError { - const error = errorResponse.error; - - // Handle both single error and error list - if (Array.isArray(error)) { - // ErrorList - get first error - const firstError = error[0]; - return new SAPAIError( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - firstError?.message ?? "Unknown orchestration error", + return error; + } + + const rootError = error instanceof Error && isErrorWithCause(error) ? error.rootCause : error; + + if (isOrchestrationErrorResponse(rootError)) { + return convertSAPErrorToAPICallError(rootError, { + ...context, + responseHeaders: context?.responseHeaders ?? getAxiosResponseHeaders(error), + }); + } + + if (rootError instanceof Error) { + const parsedError = tryExtractSAPErrorFromMessage(rootError.message); + if (parsedError && isOrchestrationErrorResponse(parsedError)) { + return convertSAPErrorToAPICallError(parsedError, { + ...context, + responseHeaders: context?.responseHeaders ?? getAxiosResponseHeaders(error), + }); + } + } + + if (rootError instanceof Error) { + const errorMsg = rootError.message.toLowerCase(); + const originalErrorMsg = rootError.message; + + if ( + errorMsg.includes("authentication") || + errorMsg.includes("unauthorized") || + errorMsg.includes("aicore_service_key") || + errorMsg.includes("invalid credentials") || + errorMsg.includes("service credentials") || + errorMsg.includes("service binding") + ) { + return new LoadAPIKeyError({ + message: + `SAP AI Core authentication failed: ${originalErrorMsg}\n\n` + + `Make sure your AICORE_SERVICE_KEY environment variable is set correctly.\n` + + `See: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/create-service-key`, + }); + } + + if ( + errorMsg.includes("econnrefused") || + errorMsg.includes("enotfound") || + errorMsg.includes("network") || + errorMsg.includes("timeout") + ) { + return createAPICallError( + error, { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - code: firstError?.code, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - location: firstError?.location, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - requestId: firstError?.request_id, + isRetryable: true, + message: `Network error connecting to SAP AI Core: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.SERVICE_UNAVAILABLE, }, + context, ); - } else { - // Single Error object - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return new SAPAIError(error.message ?? "Unknown orchestration error", { - code: error.code, - location: error.location, - requestId: error.request_id, + } + + if (errorMsg.includes("could not resolve destination")) { + return createAPICallError( + error, + { + isRetryable: false, + message: + `SAP AI Core destination error: ${originalErrorMsg}\n\n` + + `Check your destination configuration or provide a valid destinationName.`, + statusCode: HTTP_STATUS.BAD_REQUEST, + }, + context, + ); + } + + if ( + errorMsg.includes("failed to resolve deployment") || + errorMsg.includes("no deployment matched") + ) { + const modelId = extractModelIdentifier(originalErrorMsg); + return new NoSuchModelError({ + message: + `SAP AI Core deployment error: ${originalErrorMsg}\n\n` + + `Make sure you have a running orchestration deployment in your AI Core instance.\n` + + `See: https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/create-deployment-for-orchestration`, + modelId: modelId ?? "unknown", + modelType: "languageModel", }); } - } - /** - * Creates a SAPAIError from a generic error. - * - * @param error - The original error - * @param context - Optional context about where the error occurred - * @returns A new SAPAIError instance - */ - static fromError(error: unknown, context?: string): SAPAIError { - if (error instanceof SAPAIError) { - return error; - } - - let message: string; - if (error instanceof Error) { - message = error.message; - } else if (error == null) { - message = "Unknown error"; - } else if ( - typeof error === "string" || - typeof error === "number" || - typeof error === "boolean" || - typeof error === "bigint" + if (errorMsg.includes("filtered by the output filter")) { + return createAPICallError( + error, + { + isRetryable: false, + message: + `Content was filtered: ${originalErrorMsg}\n\n` + + `The model's response was blocked by content safety filters. Try a different prompt.`, + statusCode: HTTP_STATUS.BAD_REQUEST, + }, + context, + ); + } + + const statusMatch = /status code (\d+)/i.exec(originalErrorMsg); + if (statusMatch?.[1]) { + const extractedStatus = Number.parseInt(statusMatch[1], 10); + return createAPICallError( + error, + { + isRetryable: isRetryable(extractedStatus), + message: `SAP AI Core request failed: ${originalErrorMsg}`, + statusCode: extractedStatus, + }, + context, + ); + } + + if (errorMsg.includes("consumed stream")) { + return createAPICallError( + error, + { + isRetryable: false, + message: `SAP AI Core stream consumption error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); + } + + if ( + errorMsg.includes("iterating over") || + errorMsg.includes("parse message into json") || + errorMsg.includes("received from") || + errorMsg.includes("no body") || + errorMsg.includes("invalid sse payload") ) { - // Primitives that can be safely stringified - message = String(error); - } else { - // Objects, symbols, and other types - try { - message = JSON.stringify(error); - } catch { - message = "[Unstringifiable Value]"; - } + return createAPICallError( + error, + { + isRetryable: true, + message: `SAP AI Core streaming error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); + } + + if ( + errorMsg.includes("prompt template or messages must be defined") || + errorMsg.includes("filtering parameters cannot be empty") || + errorMsg.includes("templating yaml string must be non-empty") || + errorMsg.includes("could not access response data") || + errorMsg.includes("could not parse json") || + errorMsg.includes("error parsing yaml") || + errorMsg.includes("yaml does not conform") || + errorMsg.includes("validation errors") + ) { + return createAPICallError( + error, + { + isRetryable: false, + message: `SAP AI Core configuration error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.BAD_REQUEST, + }, + context, + ); + } + + if (errorMsg.includes("buffer is not available as globals")) { + return createAPICallError( + error, + { + isRetryable: false, + message: `SAP AI Core environment error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); + } + + if (errorMsg.includes("response stream is undefined")) { + return createAPICallError( + error, + { + isRetryable: false, + message: `SAP AI Core response stream error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); + } + + if ( + errorMsg.includes("response is required to process") || + errorMsg.includes("stream is still open") || + errorMsg.includes("data is not available yet") + ) { + return createAPICallError( + error, + { + isRetryable: true, + message: `SAP AI Core response processing error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); + } + + if (errorMsg.includes("failed to fetch the list of deployments")) { + return createAPICallError( + error, + { + isRetryable: true, + message: `SAP AI Core deployment retrieval error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.SERVICE_UNAVAILABLE, + }, + context, + ); + } + + if (errorMsg.includes("received non-uint8array")) { + return createAPICallError( + error, + { + isRetryable: false, + message: `SAP AI Core stream buffer error: ${originalErrorMsg}`, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); + } + } + + const message = + rootError instanceof Error + ? rootError.message + : typeof rootError === "string" + ? rootError + : "Unknown error occurred"; + + const fullMessage = context?.operation + ? `SAP AI Core ${context.operation} failed: ${message}` + : `SAP AI Core error: ${message}`; + + return createAPICallError( + error, + { + isRetryable: false, + message: fullMessage, + statusCode: HTTP_STATUS.INTERNAL_ERROR, + }, + context, + ); +} + +/** + * Normalizes various header formats to a string record. + * @param headers - The headers to normalize (various formats accepted). + * @returns The normalized headers, or undefined if empty or invalid. + */ +export function normalizeHeaders(headers: unknown): Record | undefined { + if (!headers || typeof headers !== "object") return undefined; + + const record = headers as Record; + const entries = Object.entries(record).flatMap(([key, value]) => { + if (typeof value === "string") return [[key, value]]; + if (Array.isArray(value)) { + const strings = value.filter((item): item is string => typeof item === "string").join("; "); + return strings.length > 0 ? [[key, strings]] : []; + } + if (typeof value === "number" || typeof value === "boolean") { + return [[key, String(value)]]; } + return []; + }); + + if (entries.length === 0) return undefined; + return Object.fromEntries(entries) as Record; +} + +/** + * Creates an APICallError with automatic response extraction and optional message enrichment. + * @param error - The original error to extract response data from. + * @param options - Error configuration options. + * @param options.enrichMessage - Whether to append response body to message. + * @param options.isRetryable - Whether the error should be retried. + * @param options.message - The error message. + * @param options.statusCode - The HTTP status code. + * @param context - Optional context for error details. + * @param context.operation - The operation that failed. + * @param context.requestBody - The original request body. + * @param context.responseHeaders - Pre-extracted response headers (if available). + * @param context.url - The request URL. + * @returns A configured APICallError instance with extracted response data. + * @internal + */ +function createAPICallError( + error: unknown, + options: { + enrichMessage?: boolean; + isRetryable: boolean; + message: string; + statusCode: number; + }, + context?: { + operation?: string; + requestBody?: unknown; + responseHeaders?: Record; + url?: string; + }, +): APICallError { + const responseBody = getAxiosResponseBody(error); + const responseHeaders = context?.responseHeaders ?? getAxiosResponseHeaders(error); + + const enrichMessage = options.enrichMessage ?? true; + const message = + enrichMessage && responseBody + ? `${options.message}\n\nSAP AI Core Error Response:\n${responseBody}` + : options.message; - return new SAPAIError(context ? `${context}: ${message}` : message, { - cause: error, + return new APICallError({ + cause: error, + isRetryable: options.isRetryable, + message: message, + requestBodyValues: context?.requestBody, + responseBody, + responseHeaders, + statusCode: options.statusCode, + url: context?.url ?? "", + }); +} + +/** + * Extracts model identifier from error message or location. + * @param message - The error message to parse. + * @param location - Optional error location string. + * @returns The extracted model identifier, or undefined if not found. + * @internal + */ +function extractModelIdentifier(message: string, location?: string): string | undefined { + const patterns = [ + /deployment[:\s]+([a-zA-Z0-9_-]+)/i, + /model[:\s]+([a-zA-Z0-9_-]+)/i, + /resource[:\s]+([a-zA-Z0-9_-]+)/i, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(message); + if (match?.[1]) { + return match[1]; + } + } + + if (location) { + const locationMatch = /([a-zA-Z0-9_-]+)/.exec(location); + if (locationMatch?.[1]) { + return locationMatch[1]; + } + } + + return undefined; +} + +/** + * Extracts the root cause from an error, handling ErrorWithCause chains. + * @param error - The error to extract the root cause from. + * @returns The root cause if it's an axios error, undefined otherwise. + * @internal + */ +function getAxiosError( + error: unknown, +): undefined | { isAxiosError: true; response?: { data?: unknown; headers?: unknown } } { + if (!(error instanceof Error)) return undefined; + + const rootCause = isErrorWithCause(error) ? error.rootCause : error; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typeof null === "object" in JS + if (typeof rootCause !== "object" || rootCause === null) return undefined; + + const maybeAxios = rootCause as { + isAxiosError?: boolean; + response?: { data?: unknown; headers?: unknown }; + }; + + if (maybeAxios.isAxiosError !== true) return undefined; + return maybeAxios as { isAxiosError: true; response?: { data?: unknown; headers?: unknown } }; +} + +/** + * Extracts and formats axios response body from an error. + * @param error - The error to extract response body from. + * @returns The formatted response body, or undefined if not available. + * @internal + */ +function getAxiosResponseBody(error: unknown): string | undefined { + const axiosError = getAxiosError(error); + if (!axiosError?.response?.data) return undefined; + return serializeAxiosResponseData(axiosError.response.data); +} + +/** + * Extracts response headers from an Axios error. + * @param error - The error to extract headers from. + * @returns The response headers, or undefined if not available. + * @internal + */ +function getAxiosResponseHeaders(error: unknown): Record | undefined { + const axiosError = getAxiosError(error); + if (!axiosError) return undefined; + return normalizeHeaders(axiosError.response?.headers); +} + +/** + * Maps SAP error codes to HTTP status codes (100-599 range, fallback to 500). + * @param code - The SAP error code to map. + * @returns The corresponding HTTP status code (500 if unmappable). + * @internal + */ +function getStatusCodeFromSAPError(code?: number): number { + if (!code) return HTTP_STATUS.INTERNAL_ERROR; + + if (code >= 100 && code < 600) { + return code; + } + + return HTTP_STATUS.INTERNAL_ERROR; +} + +/** + * Type guard for SAP AI SDK OrchestrationErrorResponse. + * @param error - The value to check. + * @returns True if the value is an OrchestrationErrorResponse. + * @internal + */ +function isOrchestrationErrorResponse(error: unknown): error is OrchestrationErrorResponse { + if (error === null || typeof error !== "object" || !("error" in error)) { + return false; + } + + const errorEnvelope = error as { error?: unknown }; + const innerError = errorEnvelope.error; + + if (innerError === undefined) return false; + + if (Array.isArray(innerError)) { + return innerError.every((entry) => { + if (entry === null || typeof entry !== "object" || !("message" in entry)) { + return false; + } + const errorEntry = entry as { code?: unknown; message?: unknown }; + if (typeof errorEntry.message !== "string") { + return false; + } + if ("code" in entry && typeof errorEntry.code !== "number") { + return false; + } + return true; }); } + + if (typeof innerError !== "object" || innerError === null || !("message" in innerError)) { + return false; + } + + const errorObj = innerError as { code?: unknown; message?: unknown }; + if (typeof errorObj.message !== "string") { + return false; + } + if ("code" in innerError && typeof errorObj.code !== "number") { + return false; + } + + return true; +} + +/** + * Checks if HTTP status code is retryable (408, 409, 429, 5xx). + * @param statusCode - The HTTP status code to check. + * @returns True if the request should be retried. + * @internal + */ +function isRetryable(statusCode: number): boolean { + return ( + statusCode === HTTP_STATUS.REQUEST_TIMEOUT || + statusCode === HTTP_STATUS.CONFLICT || + statusCode === HTTP_STATUS.RATE_LIMIT || + (statusCode >= HTTP_STATUS.INTERNAL_ERROR && statusCode < 600) + ); +} + +/** + * Serializes and truncates axios response data for error messages. + * @param data - The response data to serialize. + * @param maxLength - Maximum length before truncation. + * @returns Serialized and truncated string, or undefined if data is undefined. + * @internal + */ +function serializeAxiosResponseData(data: unknown, maxLength = 2000): string | undefined { + if (data === undefined) return undefined; + + let serialized: string; + try { + if (typeof data === "string") { + serialized = data; + } else { + serialized = JSON.stringify(data, null, 2); + } + } catch { + serialized = `[Unable to serialize: ${typeof data}]`; + } + + if (serialized.length > maxLength) { + return serialized.slice(0, maxLength) + "...[truncated]"; + } + return serialized; +} + +/** + * Attempts to extract a SAP error from an error message. + * @param message - The error message to parse for embedded JSON. + * @returns The extracted error object, or null if not found. + * @internal + */ +function tryExtractSAPErrorFromMessage(message: string): unknown { + const jsonMatch = /\{[\s\S]*\}/.exec(message); + if (!jsonMatch) return null; + + try { + const parsed: unknown = JSON.parse(jsonMatch[0]); + + if (parsed && typeof parsed === "object" && "error" in parsed) { + return parsed; + } + + if (parsed && typeof parsed === "object" && "message" in parsed) { + return { error: parsed as Record }; + } + + return null; + } catch { + return null; + } } -// Re-export the error response type from SAP AI SDK export type { OrchestrationErrorResponse } from "@sap-ai-sdk/orchestration"; diff --git a/src/sap-ai-language-model.test.ts b/src/sap-ai-language-model.test.ts new file mode 100644 index 0000000..c8d8bb9 --- /dev/null +++ b/src/sap-ai-language-model.test.ts @@ -0,0 +1,3411 @@ +/** Unit tests for SAP AI Language Model. */ + +import type { + LanguageModelV3FunctionTool, + LanguageModelV3Prompt, + LanguageModelV3ProviderTool, + LanguageModelV3StreamPart, +} from "@ai-sdk/provider"; + +import { describe, expect, it, vi } from "vitest"; + +import { SAPAILanguageModel } from "./sap-ai-language-model"; + +vi.mock("@sap-ai-sdk/orchestration", () => { + class MockOrchestrationClient { + static chatCompletionError: Error | undefined; + static chatCompletionResponse: + | undefined + | { + getContent: () => null | string; + getFinishReason: () => string; + getTokenUsage: () => { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + }; + getToolCalls: () => + | undefined + | { function: { arguments: string; name: string }; id: string }[]; + rawResponse?: { headers?: Record }; + }; + static lastChatCompletionRequest: unknown; + static lastChatCompletionRequestConfig: unknown; + + static lastStreamAbortSignal: unknown; + + static lastStreamConfig: unknown; + + static lastStreamRequest: unknown; + + static streamChunks: + | undefined + | { + getDeltaContent: () => null | string; + getDeltaToolCalls: () => + | undefined + | { + function?: { arguments?: string; name?: string }; + id?: string; + index: number; + }[]; + getFinishReason: () => null | string | undefined; + getTokenUsage: () => + | undefined + | { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + }; + }[]; + static streamError: Error | undefined; + static streamSetupError: Error | undefined; + + chatCompletion = vi.fn().mockImplementation((request, requestConfig) => { + MockOrchestrationClient.lastChatCompletionRequest = request; + MockOrchestrationClient.lastChatCompletionRequestConfig = requestConfig; + + const errorToThrow = MockOrchestrationClient.chatCompletionError; + if (errorToThrow) { + MockOrchestrationClient.chatCompletionError = undefined; + throw errorToThrow; + } + + if (MockOrchestrationClient.chatCompletionResponse) { + const response = MockOrchestrationClient.chatCompletionResponse; + MockOrchestrationClient.chatCompletionResponse = undefined; + return Promise.resolve(response); + } + + const messages = (request as { messages?: unknown[] }).messages; + const hasImage = + messages?.some( + (msg) => + typeof msg === "object" && + msg !== null && + "content" in msg && + Array.isArray((msg as { content?: unknown }).content), + ) ?? false; + + if (hasImage) { + throw new Error("boom"); + } + + return Promise.resolve({ + getContent: () => "Hello!", + getFinishReason: () => "stop", + getTokenUsage: () => ({ + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }), + getToolCalls: () => undefined, + rawResponse: { + headers: { + "x-request-id": "test-request-id", + }, + }, + }); + }); + + stream = vi.fn().mockImplementation((request, abortSignal, config) => { + MockOrchestrationClient.lastStreamRequest = request; + MockOrchestrationClient.lastStreamAbortSignal = abortSignal; + MockOrchestrationClient.lastStreamConfig = config; + + if (MockOrchestrationClient.streamSetupError) { + const error = MockOrchestrationClient.streamSetupError; + MockOrchestrationClient.streamSetupError = undefined; + throw error; + } + + const chunks = + MockOrchestrationClient.streamChunks ?? + ([ + { + getDeltaContent: () => "Hello", + getDeltaToolCalls: () => undefined, + getFinishReason: () => null, + getTokenUsage: () => undefined, + }, + { + getDeltaContent: () => "!", + getDeltaToolCalls: () => undefined, + getFinishReason: () => "stop", + getTokenUsage: () => ({ + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }), + }, + ] as const); + + let lastFinishReason: null | string | undefined; + let lastTokenUsage: + | undefined + | { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + }; + + for (const chunk of chunks) { + const fr = chunk.getFinishReason(); + if (fr !== null && fr !== undefined) { + lastFinishReason = fr; + } + const tu = chunk.getTokenUsage(); + if (tu) { + lastTokenUsage = tu; + } + } + + const errorToThrow = MockOrchestrationClient.streamError; + + return { + getFinishReason: () => lastFinishReason, + getTokenUsage: () => + lastTokenUsage ?? { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + stream: { + *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + if (errorToThrow) { + throw errorToThrow; + } + }, + }, + }; + }); + + static setChatCompletionError(error: Error) { + MockOrchestrationClient.chatCompletionError = error; + } + + static setChatCompletionResponse( + response: typeof MockOrchestrationClient.chatCompletionResponse, + ) { + MockOrchestrationClient.chatCompletionResponse = response; + } + + static setStreamChunks( + chunks: { + getDeltaContent: () => null | string; + getDeltaToolCalls: () => + | undefined + | { + function?: { arguments?: string; name?: string }; + id?: string; + index: number; + }[]; + getFinishReason: () => null | string | undefined; + getTokenUsage: () => + | undefined + | { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + }; + }[], + ) { + MockOrchestrationClient.streamChunks = chunks; + MockOrchestrationClient.streamError = undefined; + } + + static setStreamError(error: Error) { + MockOrchestrationClient.streamError = error; + } + + static setStreamSetupError(error: Error) { + MockOrchestrationClient.streamSetupError = error; + } + } + + return { + OrchestrationClient: MockOrchestrationClient, + }; +}); + +describe("SAPAILanguageModel", () => { + const createModel = (modelId = "gpt-4o", settings: unknown = {}) => { + return new SAPAILanguageModel( + modelId, + settings as ConstructorParameters[1], + { + deploymentConfig: { resourceGroup: "default" }, + provider: "sap-ai", + }, + ); + }; + + const createPrompt = (text: string): LanguageModelV3Prompt => [ + { content: [{ text, type: "text" }], role: "user" }, + ]; + + const expectRequestBodyHasMessages = (result: { request?: { body?: unknown } }) => { + const body: unknown = result.request?.body; + expect(body).toBeTruthy(); + expect(typeof body).toBe("object"); + expect(body).toHaveProperty("messages"); + }; + + const expectToOmitKeys = (value: unknown, keys: string[]) => { + expect(value).toBeTruthy(); + expect(typeof value).toBe("object"); + + for (const key of keys) { + expect(value).not.toHaveProperty(key); + } + }; + + const setStreamChunks = async (chunks: unknown[]) => { + const MockClient = await getMockClient(); + if (!MockClient.setStreamChunks) { + throw new Error("mock missing setStreamChunks"); + } + MockClient.setStreamChunks(chunks); + }; + + const getMockClient = async () => { + const { OrchestrationClient } = await import("@sap-ai-sdk/orchestration"); + return OrchestrationClient as unknown as { + lastChatCompletionRequest: unknown; + lastChatCompletionRequestConfig: unknown; + lastStreamAbortSignal: unknown; + lastStreamConfig: unknown; + lastStreamRequest: unknown; + setChatCompletionError?: (error: Error) => void; + setChatCompletionResponse?: (response: unknown) => void; + setStreamChunks?: (chunks: unknown[]) => void; + setStreamError?: (error: Error) => void; + setStreamSetupError?: (error: Error) => void; + }; + }; + + type OrchestrationChatCompletionRequest = Record & { + messages?: unknown; + model?: { + name?: string; + params?: Record; + version?: string; + }; + response_format?: unknown; + tools?: unknown; + }; + + const getLastChatCompletionRequest = async () => { + const MockClient = await getMockClient(); + return MockClient.lastChatCompletionRequest as OrchestrationChatCompletionRequest; + }; + + const getLastChatCompletionRequestConfig = async () => { + const MockClient = await getMockClient(); + return MockClient.lastChatCompletionRequestConfig as Record | undefined; + }; + + const getLastStreamRequest = async () => { + const MockClient = await getMockClient(); + return MockClient.lastStreamRequest as OrchestrationChatCompletionRequest; + }; + + const expectRequestBodyHasMessagesAndNoWarnings = (result: { + request?: { body?: unknown }; + warnings: unknown[]; + }) => { + expect(result.warnings).toHaveLength(0); + expectRequestBodyHasMessages(result); + }; + + const expectWarningMessageContains = ( + warnings: { message?: string; type: string }[], + substring: string, + ) => { + expect( + warnings.some( + (warning) => typeof warning.message === "string" && warning.message.includes(substring), + ), + ).toBe(true); + }; + + /** + * Creates a mock chat response with sensible defaults that can be overridden. + * @param overrides - Optional values to override defaults. + * @param overrides.content - The response content. + * @param overrides.finishReason - The finish reason. + * @param overrides.headers - Response headers. + * @param overrides.toolCalls - Tool calls in the response. + * @param overrides.usage - Token usage information. + * @param overrides.usage.completion_tokens - Completion token count. + * @param overrides.usage.prompt_tokens - Prompt token count. + * @param overrides.usage.total_tokens - Total token count. + * @returns A mock chat response object. + */ + const createMockChatResponse = ( + overrides: { + content?: null | string; + finishReason?: string; + headers?: Record; + toolCalls?: { + function: { arguments: string; name: string }; + id: string; + }[]; + usage?: { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + }; + } = {}, + ) => { + const defaults = { + content: "Hello!", + finishReason: "stop", + headers: { "x-request-id": "test-request-id" }, + toolCalls: undefined, + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }; + + const merged = { ...defaults, ...overrides }; + + return { + getContent: () => merged.content, + getFinishReason: () => merged.finishReason, + getTokenUsage: () => merged.usage, + getToolCalls: () => merged.toolCalls, + rawResponse: { headers: merged.headers }, + }; + }; + + /** + * Creates stream chunks with sensible defaults. + * @param overrides - Optional values to override defaults. + * @param overrides._data - Raw chunk data. + * @param overrides.deltaContent - Delta content for streaming. + * @param overrides.deltaToolCalls - Delta tool calls for streaming. + * @param overrides.finishReason - The finish reason. + * @param overrides.usage - Token usage information. + * @returns A mock stream chunk object. + */ + const createMockStreamChunk = ( + overrides: { + _data?: unknown; + deltaContent?: null | string; + deltaToolCalls?: { + function?: { arguments?: string; name?: string }; + id?: string; + index: number; + }[]; + finishReason?: null | string | undefined; + usage?: + | undefined + | { + completion_tokens: number; + prompt_tokens: number; + total_tokens: number; + }; + } = {}, + ) => { + const defaults = { + _data: undefined, + deltaContent: null, + deltaToolCalls: undefined, + finishReason: null, + usage: undefined, + }; + + const merged = { ...defaults, ...overrides }; + + return { + _data: merged._data, + getDeltaContent: () => merged.deltaContent, + getDeltaToolCalls: () => merged.deltaToolCalls, + getFinishReason: () => merged.finishReason, + getTokenUsage: () => merged.usage, + }; + }; + + describe("model properties", () => { + it("should have correct specification version", () => { + const model = createModel(); + expect(model.specificationVersion).toBe("v3"); + }); + + it("should have correct model ID", () => { + const model = createModel("gpt-4o"); + expect(model.modelId).toBe("gpt-4o"); + }); + + it("should have correct provider", () => { + const model = createModel(); + expect(model.provider).toBe("sap-ai"); + }); + + it.each([ + { + expected: false, + name: "should not support HTTP URLs", + url: "http://example.com/image.png", + }, + { expected: true, name: "should support data URLs", url: "data:image/png;base64,Zm9v" }, + ])("$name", ({ expected, url }) => { + const model = createModel(); + expect(model.supportsUrl(new URL(url))).toBe(expected); + }); + + it("should have supportedUrls getter for image types", () => { + const model = createModel(); + const urls = model.supportedUrls; + + expect(urls).toHaveProperty("image/*"); + expect(urls["image/*"]).toHaveLength(2); + expect(urls["image/*"]?.[0]?.test("https://example.com/image.png")).toBe(true); + expect(urls["image/*"]?.[0]?.test("http://example.com/image.png")).toBe(false); + expect(urls["image/*"]?.[1]?.test("data:image/png;base64,Zm9v")).toBe(true); + }); + + describe("model capabilities", () => { + const expectedCapabilities = { + supportsImageUrls: true, + supportsMultipleCompletions: true, + supportsParallelToolCalls: true, + supportsStreaming: true, + supportsStructuredOutputs: true, + supportsToolCalls: true, + }; + + it.each([ + "any-model", + "gpt-4o", + "anthropic--claude-3.5-sonnet", + "gemini-2.0-flash", + "amazon--nova-pro", + "mistralai--mistral-large-instruct", + "unknown-future-model", + ])("should have consistent capabilities for model %s", (modelId) => { + const model = createModel(modelId); + expect(model).toMatchObject(expectedCapabilities); + }); + }); + }); + + describe("constructor validation", () => { + it.each([ + { name: "valid modelParams", params: { maxTokens: 1000, temperature: 0.7, topP: 0.9 } }, + { name: "empty modelParams", params: {} }, + { name: "no modelParams", params: undefined }, + ])("should accept $name", ({ params }) => { + expect(() => createModel("gpt-4o", params ? { modelParams: params } : {})).not.toThrow(); + }); + + it.each([ + { name: "temperature too high", params: { temperature: 3 } }, + { name: "temperature negative", params: { temperature: -1 } }, + { name: "topP out of range", params: { topP: 1.5 } }, + { name: "non-positive maxTokens", params: { maxTokens: 0 } }, + { name: "non-integer maxTokens", params: { maxTokens: 100.5 } }, + { name: "frequencyPenalty out of range", params: { frequencyPenalty: -3 } }, + { name: "presencePenalty out of range", params: { presencePenalty: 2.5 } }, + ])("should throw on $name", ({ params }) => { + expect(() => createModel("gpt-4o", { modelParams: params })).toThrow(); + }); + + it.each([ + { name: "non-positive n", params: { n: 0 } }, + { name: "non-boolean parallel_tool_calls", params: { parallel_tool_calls: "true" } }, + ])("should throw on $name", ({ params }) => { + expect(() => createModel("gpt-4o", { modelParams: params })).toThrow(); + }); + }); + + describe("doGenerate", () => { + it("should generate text response", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ text: "Hello!", type: "text" }); + expect(result.finishReason).toEqual({ raw: "stop", unified: "stop" }); + expect(result.usage).toEqual({ + inputTokens: { + cacheRead: undefined, + cacheWrite: undefined, + noCache: 10, + total: 10, + }, + outputTokens: { reasoning: undefined, text: 5, total: 5 }, + }); + expect(result.response?.headers).toBeDefined(); + expect(result.response?.headers).toMatchObject({ + "x-request-id": "test-request-id", + }); + expect(result.providerMetadata).toEqual({ + "sap-ai": { + finishReason: "stop", + finishReasonMapped: { raw: "stop", unified: "stop" }, + requestId: "test-request-id", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(String), + }, + }); + }); + + describe("error handling", () => { + it("should propagate axios response headers into doGenerate errors", async () => { + const MockClient = await getMockClient(); + if (!MockClient.setChatCompletionError) { + throw new Error("mock missing setChatCompletionError"); + } + + const axiosError = new Error("Request failed") as Error & { + isAxiosError: boolean; + response: { headers: Record }; + }; + axiosError.isAxiosError = true; + axiosError.response = { + headers: { + "x-request-id": "do-generate-axios-123", + }, + }; + + MockClient.setChatCompletionError(axiosError); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + await expect(model.doGenerate({ prompt })).rejects.toMatchObject({ + responseHeaders: { + "x-request-id": "do-generate-axios-123", + }, + }); + }); + + it("should sanitize requestBodyValues in errors", async () => { + const model = createModel(); + + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + data: "BASE64_IMAGE_DATA", + mediaType: "image/png", + type: "file", + }, + ], + role: "user", + }, + ]; + + let caught: unknown; + try { + await model.doGenerate({ prompt }); + } catch (error: unknown) { + caught = error; + } + + const caughtError = caught as { + name?: string; + requestBodyValues?: unknown; + }; + + expect(caughtError.name).toEqual(expect.stringContaining("APICallError")); + expect(caughtError.requestBodyValues).toMatchObject({ + hasImageParts: true, + promptMessages: 1, + }); + }); + }); + + describe("abort signal support", () => { + it("should pass abort signal to chatCompletion via requestConfig", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + const controller = new AbortController(); + + await model.doGenerate({ abortSignal: controller.signal, prompt }); + + const requestConfig = await getLastChatCompletionRequestConfig(); + expect(requestConfig).toBeDefined(); + expect(requestConfig).toHaveProperty("signal", controller.signal); + }); + + it("should not pass requestConfig when abort signal is not provided", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + await model.doGenerate({ prompt }); + + const requestConfig = await getLastChatCompletionRequestConfig(); + expect(requestConfig).toBeUndefined(); + }); + + it("should propagate error when chatCompletion rejects due to abort", async () => { + const MockClient = await getMockClient(); + + const abortError = new Error("Request aborted") as Error & { + isAxiosError: boolean; + response?: { headers?: Record }; + }; + abortError.isAxiosError = true; + + if (!MockClient.setChatCompletionError) { + throw new Error("mock missing setChatCompletionError"); + } + + MockClient.setChatCompletionError(abortError); + + const model = createModel(); + const prompt = createPrompt("Hello"); + const controller = new AbortController(); + + await expect( + model.doGenerate({ abortSignal: controller.signal, prompt }), + ).rejects.toThrow(); + }); + }); + + it("should pass tools to orchestration config", async () => { + const model = createModel(); + const prompt = createPrompt("What is 2+2?"); + + const tools: LanguageModelV3FunctionTool[] = [ + { + description: "Perform calculation", + inputSchema: { + properties: { + expression: { type: "string" }, + }, + required: ["expression"], + type: "object", + }, + name: "calculate", + type: "function", + }, + ]; + + const result = await model.doGenerate({ prompt, tools }); + + expectRequestBodyHasMessagesAndNoWarnings(result); + }); + + it("should pass parallel_tool_calls when configured", async () => { + const model = createModel("gpt-4o", { + modelParams: { + parallel_tool_calls: true, + }, + }); + + const prompt = createPrompt("Hi"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + }); + + it("should apply providerOptions.sap-ai overrides", async () => { + const model = createModel("gpt-4o", { + includeReasoning: false, + modelParams: { + temperature: 0.1, + }, + modelVersion: "settings-version", + }); + + const prompt = createPrompt("Hi"); + + const result = await model.doGenerate({ + prompt, + providerOptions: { + "sap-ai": { + includeReasoning: true, + modelParams: { + temperature: 0.9, + }, + }, + }, + }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request.model?.params?.temperature).toBe(0.9); + }); + + it("should map responseFormat json without schema to json_object", async () => { + const model = createModel(); + + const prompt = createPrompt("Return JSON"); + + const result = await model.doGenerate({ + prompt, + responseFormat: { type: "json" }, + }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.response_format).toEqual({ type: "json_object" }); + }); + + it("should map responseFormat json with schema to json_schema", async () => { + const model = createModel(); + + const prompt = createPrompt("Return JSON"); + + const schema = { + additionalProperties: false, + properties: { + answer: { type: "string" as const }, + }, + required: ["answer"], + type: "object" as const, + }; + + const result = await model.doGenerate({ + prompt, + responseFormat: { + description: "A structured response", + name: "response", + schema, + type: "json", + }, + }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.response_format).toEqual({ + json_schema: { + description: "A structured response", + name: "response", + schema, + strict: null, + }, + type: "json_schema", + }); + }); + + it("should use settings.responseFormat as fallback when options.responseFormat is not provided", async () => { + const model = createModel("gpt-4o", { + responseFormat: { + json_schema: { + description: "Settings-level schema", + name: "settings_response", + schema: { properties: { value: { type: "string" } }, type: "object" }, + strict: true, + }, + type: "json_schema", + }, + }); + + const prompt = createPrompt("Return JSON"); + + await model.doGenerate({ prompt }); + + const request = await getLastChatCompletionRequest(); + + expect(request.response_format).toEqual({ + json_schema: { + description: "Settings-level schema", + name: "settings_response", + schema: { properties: { value: { type: "string" } }, type: "object" }, + strict: true, + }, + type: "json_schema", + }); + }); + + it("should prefer options.responseFormat over settings.responseFormat", async () => { + const model = createModel("gpt-4o", { + responseFormat: { + json_schema: { + description: "Settings-level schema", + name: "settings_response", + schema: { properties: { value: { type: "string" } }, type: "object" }, + }, + type: "json_schema", + }, + }); + + const prompt = createPrompt("Return JSON"); + + const optionsSchema = { + additionalProperties: false, + properties: { answer: { type: "string" as const } }, + required: ["answer"], + type: "object" as const, + }; + + await model.doGenerate({ + prompt, + responseFormat: { + description: "Options-level schema", + name: "options_response", + schema: optionsSchema, + type: "json", + }, + }); + + const request = await getLastChatCompletionRequest(); + + expect(request.response_format).toEqual({ + json_schema: { + description: "Options-level schema", + name: "options_response", + schema: optionsSchema, + strict: null, + }, + type: "json_schema", + }); + }); + + it("should warn about unsupported tool types", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const tools = [ + { + args: {}, + id: "custom-tool", + type: "provider-defined" as const, + }, + ]; + + const result = await model.doGenerate({ + prompt, + tools: tools as unknown as LanguageModelV3ProviderTool[], + }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]?.type).toBe("unsupported"); + }); + + it("should prefer call options.tools over settings.tools (and warn)", async () => { + const model = createModel("gpt-4o", { + tools: [ + { + function: { + description: "From settings", + name: "settings_tool", + parameters: { + properties: {}, + required: [], + type: "object", + }, + }, + type: "function", + }, + ], + }); + + const prompt = createPrompt("Hello"); + + const tools: LanguageModelV3FunctionTool[] = [ + { + description: "From call options", + inputSchema: { + properties: {}, + required: [], + type: "object", + }, + name: "call_tool", + type: "function", + }, + ]; + + const result = await model.doGenerate({ prompt, tools }); + const warnings = result.warnings; + + expectWarningMessageContains(warnings, "preferring call options.tools"); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + const requestTools = Array.isArray(request.tools) ? (request.tools as unknown[]) : []; + + expect( + requestTools.some( + (tool) => + typeof tool === "object" && + tool !== null && + (tool as { function?: { name?: unknown } }).function?.name === "call_tool", + ), + ).toBe(true); + + expect( + requestTools.some( + (tool) => + typeof tool === "object" && + tool !== null && + (tool as { function?: { name?: unknown } }).function?.name === "settings_tool", + ), + ).toBe(false); + }); + + it("should warn when tool Zod schema conversion fails", async () => { + const model = createModel(); + const prompt = createPrompt("Use a tool"); + + const zodLikeThatThrows = { + _def: {}, + parse: () => undefined, + toJSON: () => { + throw new Error("conversion failed"); + }, + }; + + const tools: LanguageModelV3FunctionTool[] = [ + { + description: "Tool with failing Zod schema conversion", + inputSchema: {}, + name: "badTool", + parameters: zodLikeThatThrows, + type: "function", + } as unknown as LanguageModelV3FunctionTool, + ]; + + const result = await model.doGenerate({ prompt, tools }); + + expectRequestBodyHasMessages(result); + }); + + it("should include tool calls in doGenerate response content", async () => { + const MockClient = await getMockClient(); + if (!MockClient.setChatCompletionResponse) { + throw new Error("mock missing setChatCompletionResponse"); + } + + MockClient.setChatCompletionResponse( + createMockChatResponse({ + content: null, + finishReason: "tool_calls", + headers: { "x-request-id": "tool-call-test" }, + toolCalls: [ + { + function: { + arguments: '{"location":"Paris"}', + name: "get_weather", + }, + id: "call_123", + }, + ], + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ); + + const model = createModel(); + const prompt = createPrompt("What's the weather?"); + + const result = await model.doGenerate({ prompt }); + + expect(result.content).toContainEqual({ + input: '{"location":"Paris"}', + toolCallId: "call_123", + toolName: "get_weather", + type: "tool-call", + }); + expect(result.finishReason).toEqual({ + raw: "tool_calls", + unified: "tool-calls", + }); + }); + + it.each([ + { + description: "normalize array header values", + expected: { + "x-multi-value": "value1; value2", + "x-request-id": "array-header-test", + }, + headers: { + "x-multi-value": ["value1", "value2"], + "x-request-id": "array-header-test", + }, + }, + { + description: "convert numeric header values to strings", + expected: { + "content-length": "1024", + "x-retry-after": "30", + }, + headers: { + "content-length": 1024, + "x-retry-after": 30, + }, + }, + { + description: "skip unsupported header value types", + expected: { + "x-valid": "keep-this", + }, + headers: { + "x-object": { nested: "object" }, + "x-valid": "keep-this", + }, + }, + { + description: "filter non-string values from array headers", + expected: { + "x-mixed": "valid; also-valid", + }, + headers: { + "x-mixed": ["valid", 123, null, "also-valid"], + }, + }, + { + description: "exclude array headers with only non-string items", + expected: { + "x-valid": "keep-this", + }, + headers: { + "x-invalid-array": [123, null, undefined], + "x-valid": "keep-this", + }, + }, + ])("should $description in doGenerate response", async ({ expected, headers }) => { + const MockClient = await getMockClient(); + if (!MockClient.setChatCompletionResponse) { + throw new Error("mock missing setChatCompletionResponse"); + } + + MockClient.setChatCompletionResponse( + createMockChatResponse({ + content: "Response", + finishReason: "stop", + headers, + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ); + + const model = createModel(); + const prompt = createPrompt("Test"); + const result = await model.doGenerate({ prompt }); + + expect(result.response?.headers).toEqual(expected); + }); + + it("should include response body in doGenerate result", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expect(result.response?.body).toBeDefined(); + expect(result.response?.body).toHaveProperty("content"); + expect(result.response?.body).toHaveProperty("tokenUsage"); + expect(result.response?.body).toHaveProperty("finishReason"); + }); + + it("should handle large non-streaming responses without truncation (100KB+)", async () => { + const MockClient = await getMockClient(); + if (!MockClient.setChatCompletionResponse) { + throw new Error("mock missing setChatCompletionResponse"); + } + + const largeContent = "B".repeat(100000) + "[END_MARKER]"; + + MockClient.setChatCompletionResponse( + createMockChatResponse({ + content: largeContent, + finishReason: "stop", + usage: { completion_tokens: 25000, prompt_tokens: 10, total_tokens: 25010 }, + }), + ); + + const model = createModel(); + const prompt = createPrompt("Generate a very long response"); + + const result = await model.doGenerate({ prompt }); + + const textContent = result.content.find( + (c): c is { text: string; type: "text" } => c.type === "text", + ); + expect(textContent).toBeDefined(); + const text = textContent?.text ?? ""; + expect(text).toBe(largeContent); + expect(text).toHaveLength(100000 + "[END_MARKER]".length); + expect(text).toContain("[END_MARKER]"); + }); + + it("should handle tool calls with large JSON arguments without truncation", async () => { + const MockClient = await getMockClient(); + if (!MockClient.setChatCompletionResponse) { + throw new Error("mock missing setChatCompletionResponse"); + } + + const largeArgs = JSON.stringify({ + finalMarker: "COMPLETE", + items: Array.from({ length: 500 }, (_, i) => ({ + content: "C".repeat(100), + id: i, + })), + }); + + MockClient.setChatCompletionResponse( + createMockChatResponse({ + content: null, + finishReason: "tool_calls", + toolCalls: [{ function: { arguments: largeArgs, name: "large_tool" }, id: "call_large" }], + usage: { completion_tokens: 10000, prompt_tokens: 10, total_tokens: 10010 }, + }), + ); + + const model = createModel(); + const prompt = createPrompt("Call a tool"); + + const result = await model.doGenerate({ prompt }); + + const toolCallContent = result.content.filter( + (c): c is { input: string; toolCallId: string; toolName: string; type: "tool-call" } => + c.type === "tool-call", + ); + expect(toolCallContent).toHaveLength(1); + const firstToolCall = toolCallContent[0]; + expect(firstToolCall).toBeDefined(); + expect(firstToolCall?.toolName).toBe("large_tool"); + + const parsedArgs = JSON.parse(firstToolCall?.input ?? "{}") as { + finalMarker: string; + items: { id: number }[]; + }; + expect(parsedArgs.items).toHaveLength(500); + expect(parsedArgs.finalMarker).toBe("COMPLETE"); + }); + }); + + describe("doStream", () => { + it("should stream basic text (edge-runtime compatible)", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const reader = stream.getReader(); + + const parts: unknown[] = []; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + expect(parts.some((p) => (p as { type?: string }).type === "stream-start")).toBe(true); + expect(parts.some((p) => (p as { type?: string }).type === "finish")).toBe(true); + }); + + it("should not mutate stream-start warnings when warnings occur during stream", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { + arguments: '{"x":1}', + }, + id: "toolcall-0", + index: 0, + }, + ], + finishReason: "tool_calls", + usage: { + completion_tokens: 1, + prompt_tokens: 1, + total_tokens: 2, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const result = await model.doStream({ prompt }); + + const parts = await readAllParts(result.stream); + const streamStart = parts.find((part) => part.type === "stream-start"); + expect(streamStart?.warnings).toHaveLength(0); + }); + + /** + * Reads all parts from a language model stream. + * @param stream - The stream to read from. + * @returns An array of all stream parts. + */ + async function readAllParts(stream: ReadableStream) { + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + return parts; + } + + it("should not emit text deltas after tool-call deltas", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "Hello", + }), + createMockStreamChunk({ + deltaContent: " SHOULD_NOT_APPEAR", + deltaToolCalls: [ + { + function: { arguments: '{"x":', name: "calc" }, + id: "call_0", + index: 0, + }, + ], + }), + createMockStreamChunk({ + deltaContent: " ALSO_SHOULD_NOT_APPEAR", + deltaToolCalls: [ + { + function: { arguments: "1}" }, + id: "call_0", + index: 0, + }, + ], + finishReason: "tool_calls", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const textDeltas = parts.filter((p) => p.type === "text-delta"); + expect(textDeltas).toHaveLength(1); + expect((textDeltas[0] as { delta: string }).delta).toBe("Hello"); + }); + + it("should stream text response", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "Hello", + }), + createMockStreamChunk({ + deltaContent: "!", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + expect(parts[0]?.type).toBe("stream-start"); + const responseMetadata = parts.find((p) => p.type === "response-metadata"); + expect(responseMetadata).toEqual({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + id: expect.stringMatching(uuidRegex), + modelId: "gpt-4o", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + timestamp: expect.any(Date), + type: "response-metadata", + }); + expect(parts.some((p) => p.type === "text-delta")).toBe(true); + expect(parts.some((p) => p.type === "finish")).toBe(true); + + const finishPart = parts.find((p) => p.type === "finish"); + expect(finishPart).toBeDefined(); + if (finishPart?.type === "finish") { + expect(finishPart.finishReason).toEqual({ raw: "stop", unified: "stop" }); + expect(finishPart.providerMetadata).toEqual({ + "sap-ai": { + finishReason: "stop", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + responseId: expect.stringMatching(uuidRegex), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(String), + }, + }); + } + }); + + it("should emit raw chunks when includeRawChunks is true", async () => { + const rawData1 = { custom: "data1", delta: "Hello" }; + const rawData2 = { custom: "data2", delta: "!" }; + + await setStreamChunks([ + createMockStreamChunk({ + _data: rawData1, + deltaContent: "Hello", + }), + createMockStreamChunk({ + _data: rawData2, + deltaContent: "!", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ includeRawChunks: true, prompt }); + const parts = await readAllParts(stream); + const rawParts = parts.filter((p) => p.type === "raw"); + + expect(rawParts).toHaveLength(2); + expect(rawParts[0]).toEqual({ rawValue: rawData1, type: "raw" }); + expect(rawParts[1]).toEqual({ rawValue: rawData2, type: "raw" }); + }); + + it("should not emit raw chunks when includeRawChunks is false or omitted", async () => { + await setStreamChunks([ + createMockStreamChunk({ + _data: { some: "data" }, + deltaContent: "Hello", + }), + createMockStreamChunk({ + _data: { more: "data" }, + deltaContent: "!", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream: stream1 } = await model.doStream({ prompt }); + const parts1 = await readAllParts(stream1); + expect(parts1.filter((p) => p.type === "raw")).toHaveLength(0); + + await setStreamChunks([ + createMockStreamChunk({ + _data: { some: "data" }, + deltaContent: "Hello", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const { stream: stream2 } = await model.doStream({ includeRawChunks: false, prompt }); + const parts2 = await readAllParts(stream2); + expect(parts2.filter((p) => p.type === "raw")).toHaveLength(0); + }); + + it("should use chunk itself as rawValue when _data is undefined", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "Hello", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ includeRawChunks: true, prompt }); + const parts = await readAllParts(stream); + + const rawParts = parts.filter((p) => p.type === "raw"); + expect(rawParts).toHaveLength(1); + + const rawPart = rawParts[0] as { rawValue: unknown; type: "raw" }; + expect(rawPart.rawValue).toHaveProperty("getDeltaContent"); + expect(rawPart.rawValue).toHaveProperty("getFinishReason"); + }); + + it("should flush tool calls immediately on tool-calls finishReason", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: '{"city":', name: "get_weather" }, + id: "call_0", + index: 0, + }, + ], + }), + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: '"Paris"}' }, + id: "call_0", + index: 0, + }, + ], + finishReason: "tool_calls", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + createMockStreamChunk({ deltaContent: "SHOULD_NOT_APPEAR" }), + ]); + + const model = createModel(); + const prompt = createPrompt("Use tool"); + + const result = await model.doStream({ prompt }); + const parts = await readAllParts(result.stream); + + const toolCallIndex = parts.findIndex((p) => p.type === "tool-call"); + const finishIndex = parts.findIndex((p) => p.type === "finish"); + + expect(toolCallIndex).toBeGreaterThan(-1); + expect(finishIndex).toBeGreaterThan(toolCallIndex); + + const textDeltas = parts.filter((p) => p.type === "text-delta"); + const hasPostToolTextDelta = textDeltas.some( + (td) => (td as { delta: string; type: "text-delta" }).delta === "SHOULD_NOT_APPEAR", + ); + expect(hasPostToolTextDelta).toBe(false); + }); + + it.each([ + { + description: "max_tokens_reached as length", + expected: "length", + input: "max_tokens_reached", + }, + { description: "length", expected: "length", input: "length" }, + { description: "eos as stop", expected: "stop", input: "eos" }, + { + description: "stop_sequence as stop", + expected: "stop", + input: "stop_sequence", + }, + { description: "end_turn as stop", expected: "stop", input: "end_turn" }, + { + description: "content_filter", + expected: "content-filter", + input: "content_filter", + }, + { description: "error", expected: "error", input: "error" }, + { + description: "max_tokens as length", + expected: "length", + input: "max_tokens", + }, + { + description: "tool_call as tool-calls", + expected: "tool-calls", + input: "tool_call", + }, + { + description: "function_call as tool-calls", + expected: "tool-calls", + input: "function_call", + }, + { + description: "unknown reason as other", + expected: "other", + input: "some_new_unknown_reason", + }, + { + description: "undefined as other", + expected: "other", + input: undefined, + }, + ])("should handle stream with finish reason: $description", async ({ expected, input }) => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "test content", + finishReason: input, + usage: { + completion_tokens: 2, + prompt_tokens: 1, + total_tokens: 3, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const finishPart = parts.find((p) => p.type === "finish"); + expect(finishPart).toBeDefined(); + if (finishPart?.type === "finish") { + expect(finishPart.finishReason.unified).toBe(expected); + expect(finishPart.finishReason.raw).toBe(input); + } + }); + + it("should omit tools and response_format when not provided", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expectToOmitKeys(request, ["tools", "response_format"]); + }); + + it("should handle stream chunks with null content", async () => { + await setStreamChunks([ + createMockStreamChunk({}), + createMockStreamChunk({ + deltaContent: "Hello", + }), + createMockStreamChunk({ + finishReason: "stop", + usage: { + completion_tokens: 1, + prompt_tokens: 10, + total_tokens: 11, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const textDeltas = parts.filter((p) => p.type === "text-delta"); + expect(textDeltas).toHaveLength(1); + expect((textDeltas[0] as { delta: string }).delta).toBe("Hello"); + + const finishPart = parts.find((p) => p.type === "finish"); + expect(finishPart).toBeDefined(); + }); + + it("should handle stream with empty string content", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "", + }), + createMockStreamChunk({ + deltaContent: "Response", + finishReason: "stop", + usage: { + completion_tokens: 1, + prompt_tokens: 10, + total_tokens: 11, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const textDeltas = parts.filter((p) => p.type === "text-delta"); + expect(textDeltas.length).toBeGreaterThanOrEqual(1); + }); + + it("should handle large streaming responses without truncation (120KB across 12 chunks)", async () => { + const chunkSize = 10000; + const numChunks = 12; + const chunks = Array.from({ length: numChunks }, (_, i) => { + const isLast = i === numChunks - 1; + const content = + i === numChunks - 1 ? "D".repeat(chunkSize) + "[STREAM_END]" : "D".repeat(chunkSize); + return createMockStreamChunk({ + deltaContent: content, + finishReason: isLast ? "stop" : null, + usage: isLast + ? { completion_tokens: 30000, prompt_tokens: 10, total_tokens: 30010 } + : undefined, + }); + }); + + await setStreamChunks(chunks); + + const model = createModel(); + const prompt = createPrompt("Generate a very long streaming response"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const textDeltas = parts.filter((p) => p.type === "text-delta"); + + const fullText = textDeltas.map((td) => (td as { delta: string }).delta).join(""); + expect(fullText).toHaveLength( + chunkSize * (numChunks - 1) + chunkSize + "[STREAM_END]".length, + ); + expect(fullText).toContain("[STREAM_END]"); + expect(fullText.startsWith("D".repeat(100))).toBe(true); + }); + + it("should handle streaming tool calls with large JSON arguments without truncation (~50KB)", async () => { + const largeArgs = JSON.stringify({ + items: Array.from({ length: 400 }, (_, i) => ({ + data: "E".repeat(100), + id: i, + })), + marker: "TOOL_COMPLETE", + }); + + const argPart1 = largeArgs.slice(0, Math.floor(largeArgs.length / 3)); + const argPart2 = largeArgs.slice( + Math.floor(largeArgs.length / 3), + Math.floor((2 * largeArgs.length) / 3), + ); + const argPart3 = largeArgs.slice(Math.floor((2 * largeArgs.length) / 3)); + + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: argPart1, name: "large_stream_tool" }, + id: "call_large_stream", + index: 0, + }, + ], + }), + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: argPart2 }, + index: 0, + }, + ], + }), + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: argPart3 }, + index: 0, + }, + ], + finishReason: "tool_calls", + usage: { completion_tokens: 12000, prompt_tokens: 10, total_tokens: 12010 }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Call a tool with large arguments"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const toolCallPart = parts.find( + (p): p is { input: string; toolCallId: string; toolName: string; type: "tool-call" } => + p.type === "tool-call", + ); + + expect(toolCallPart).toBeDefined(); + expect(toolCallPart?.toolName).toBe("large_stream_tool"); + expect(toolCallPart?.toolCallId).toBe("call_large_stream"); + + const parsedArgs = JSON.parse(toolCallPart?.input ?? "{}") as { + items: { id: number }[]; + marker: string; + }; + expect(parsedArgs.items).toHaveLength(400); + expect(parsedArgs.marker).toBe("TOOL_COMPLETE"); + }); + + it("should handle multiple concurrent tool calls with large arguments", async () => { + const largeArgs1 = JSON.stringify({ + id: "first", + query: "F".repeat(5000), + }); + const largeArgs2 = JSON.stringify({ + id: "second", + query: "G".repeat(5000), + }); + + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: largeArgs1.slice(0, 3000), name: "tool_one" }, + id: "call_1", + index: 0, + }, + { + function: { arguments: largeArgs2.slice(0, 3000), name: "tool_two" }, + id: "call_2", + index: 1, + }, + ], + }), + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: largeArgs1.slice(3000) }, + index: 0, + }, + { + function: { arguments: largeArgs2.slice(3000) }, + index: 1, + }, + ], + finishReason: "tool_calls", + usage: { completion_tokens: 3000, prompt_tokens: 10, total_tokens: 3010 }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Call multiple tools"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const toolCalls = parts.filter( + (p): p is { input: string; toolCallId: string; toolName: string; type: "tool-call" } => + p.type === "tool-call", + ); + + expect(toolCalls).toHaveLength(2); + + const tool1 = toolCalls.find((tc) => tc.toolName === "tool_one"); + const tool2 = toolCalls.find((tc) => tc.toolName === "tool_two"); + + expect(tool1).toBeDefined(); + expect(tool2).toBeDefined(); + + const parsed1 = JSON.parse(tool1?.input ?? "{}") as { id: string; query: string }; + const parsed2 = JSON.parse(tool2?.input ?? "{}") as { id: string; query: string }; + + expect(parsed1.id).toBe("first"); + expect(parsed1.query).toHaveLength(5000); + expect(parsed2.id).toBe("second"); + expect(parsed2.query).toHaveLength(5000); + }); + + it("should handle Unicode and multi-byte characters in large streams without corruption", async () => { + const unicodeContent = + "Hello 世界! 🌍🌎🌏 Привет мир! مرحبا بالعالم " + + "日本語テスト " + + "한국어 테스트 " + + "🎉".repeat(100) + + " [UNICODE_END]"; + + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: unicodeContent.slice(0, 50), + }), + createMockStreamChunk({ + deltaContent: unicodeContent.slice(50, 150), + }), + createMockStreamChunk({ + deltaContent: unicodeContent.slice(150), + finishReason: "stop", + usage: { completion_tokens: 500, prompt_tokens: 10, total_tokens: 510 }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Generate Unicode content"); + + const { stream } = await model.doStream({ prompt }); + const parts = await readAllParts(stream); + + const textDeltas = parts.filter((p) => p.type === "text-delta"); + + const fullText = textDeltas.map((td) => (td as { delta: string }).delta).join(""); + expect(fullText).toBe(unicodeContent); + expect(fullText).toContain("世界"); + expect(fullText).toContain("🌍"); + expect(fullText).toContain("Привет"); + expect(fullText).toContain("مرحبا"); + expect(fullText).toContain("[UNICODE_END]"); + }); + + describe("error handling", () => { + it("should warn when tool call delta has no tool name", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: '{"x":1}' }, + id: "call_nameless", + index: 0, + }, + ], + finishReason: "tool_calls", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Use tool"); + + const result = await model.doStream({ prompt }); + const { stream } = result; + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + const toolCall = parts.find((p) => p.type === "tool-call"); + expect(toolCall).toBeDefined(); + expect(toolCall).toEqual({ + input: '{"x":1}', + toolCallId: "call_nameless", + toolName: "", + type: "tool-call", + }); + + const streamStart = parts.find( + (p): p is Extract => + p.type === "stream-start", + ); + expect(streamStart).toBeDefined(); + expect(streamStart?.warnings).toHaveLength(0); + + expect(parts.some((p) => p.type === "error")).toBe(false); + expect(parts.some((p) => p.type === "finish")).toBe(true); + + const finish = parts.find( + (p): p is Extract => p.type === "finish", + ); + expect(finish?.finishReason).toBeDefined(); + }); + + it("should emit error part when stream iteration throws", async () => { + const MockClient = await getMockClient(); + if (!MockClient.setStreamError) { + throw new Error("mock missing setStreamError"); + } + + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "Hello", + }), + ]); + const axiosError = new Error("Stream iteration failed") as unknown as { + isAxiosError: boolean; + response: { headers: Record }; + }; + axiosError.isAxiosError = true; + axiosError.response = { + headers: { + "x-request-id": "stream-axios-123", + }, + }; + + MockClient.setStreamError(axiosError as unknown as Error); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + const textDelta = parts.find((p) => p.type === "text-delta"); + expect(textDelta).toBeDefined(); + + const errorPart = parts.find((p) => p.type === "error"); + expect(errorPart).toBeDefined(); + expect(errorPart).toMatchObject({ + type: "error", + }); + expect((errorPart as { error: Error }).error.message).toEqual( + expect.stringContaining("Stream iteration failed"), + ); + expect( + (errorPart as { error: { responseHeaders?: unknown } }).error.responseHeaders, + ).toMatchObject({ + "x-request-id": "stream-axios-123", + }); + + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "reset", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + }); + + it("should skip tool call deltas with invalid index", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "Hello", + deltaToolCalls: [ + { + function: { arguments: "{}", name: "test_tool" }, + id: "call_invalid", + index: NaN, + }, + ], + }), + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: "{}", name: "other_tool" }, + id: "call_undefined", + index: undefined as unknown as number, + }, + ], + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + expect(parts.some((p) => p.type === "finish")).toBe(true); + expect(parts.some((p) => p.type === "tool-call")).toBe(false); + }); + + it("should generate unique RFC 4122 UUIDs for text blocks", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "First text block", + }), + createMockStreamChunk({ + deltaContent: " continuation", + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Test"); + + const { stream } = await model.doStream({ prompt }); + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + const textStarts = parts.filter( + (p): p is Extract => + p.type === "text-start", + ); + const textDeltas = parts.filter( + (p): p is Extract => + p.type === "text-delta", + ); + const textEnds = parts.filter( + (p): p is Extract => + p.type === "text-end", + ); + + expect(textStarts).toHaveLength(1); + expect(textEnds).toHaveLength(1); + expect(textDeltas.length).toBeGreaterThan(0); + + const blockId = textStarts[0]?.id; + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(blockId).toMatch(uuidRegex); + expect(blockId).not.toBe("0"); + + for (const delta of textDeltas) { + expect(delta.id).toBe(blockId); + } + expect(textEnds[0]?.id).toBe(blockId); + + const { stream: stream2 } = await model.doStream({ prompt }); + const parts2: LanguageModelV3StreamPart[] = []; + const reader2 = stream2.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader2.read(); + if (done) break; + parts2.push(value); + } + + const textStarts2 = parts2.filter( + (p): p is Extract => + p.type === "text-start", + ); + + const blockId2 = textStarts2[0]?.id; + + expect(blockId2).not.toBe(blockId); + expect(blockId2).toMatch(uuidRegex); + }); + + it("should flush unflushed tool calls at stream end (with finishReason=stop)", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: '{"q":"test"}', name: "get_info" }, + id: "call_unflushed", + index: 0, + }, + ], + }), + createMockStreamChunk({ + finishReason: "stop", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Test"); + + const { stream } = await model.doStream({ prompt }); + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + const toolCall = parts.find((p) => p.type === "tool-call"); + expect(toolCall).toBeDefined(); + expect(toolCall).toEqual({ + input: '{"q":"test"}', + toolCallId: "call_unflushed", + toolName: "get_info", + type: "tool-call", + }); + + const finish = parts.find( + (p): p is Extract => p.type === "finish", + ); + expect(finish?.finishReason).toEqual({ raw: "stop", unified: "stop" }); + }); + + it("should handle undefined finish reason from stream", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaContent: "Hello", + finishReason: undefined as unknown as string, + }), + createMockStreamChunk({ + deltaContent: "!", + finishReason: undefined as unknown as string, + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + const { stream } = await model.doStream({ prompt }); + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + const finish = parts.find( + (p): p is Extract => p.type === "finish", + ); + expect(finish?.finishReason).toEqual({ + raw: undefined, + unified: "other", + }); + }); + + it("should flush tool calls that never received input-start", async () => { + await setStreamChunks([ + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: '{"partial":' }, + id: "call_no_start", + index: 0, + }, + ], + }), + createMockStreamChunk({ + deltaToolCalls: [ + { + function: { arguments: '"value"}', name: "delayed_name" }, + index: 0, + }, + ], + finishReason: "tool_calls", + usage: { + completion_tokens: 5, + prompt_tokens: 10, + total_tokens: 15, + }, + }), + ]); + + const model = createModel(); + const prompt = createPrompt("Test"); + + const { stream } = await model.doStream({ prompt }); + const parts: LanguageModelV3StreamPart[] = []; + const reader = stream.getReader(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + + const toolCall = parts.find((p) => p.type === "tool-call"); + expect(toolCall).toBeDefined(); + expect(toolCall).toEqual({ + input: '{"partial":"value"}', + toolCallId: "call_no_start", + toolName: "delayed_name", + type: "tool-call", + }); + }); + + it("should throw converted error when doStream setup fails", async () => { + const MockClient = await getMockClient(); + if (!MockClient.setStreamSetupError) { + throw new Error("mock missing setStreamSetupError"); + } + + const setupError = new Error("Stream setup failed"); + MockClient.setStreamSetupError(setupError); + + const model = createModel(); + const prompt = createPrompt("Hello"); + + await expect(model.doStream({ prompt })).rejects.toThrow("Stream setup failed"); + }); + }); + }); + + describe("configuration", () => { + describe("masking and filtering", () => { + it.each([ + { property: "masking", settings: { masking: {} } }, + { property: "filtering", settings: { filtering: {} } }, + ])("should omit $property when empty object", async ({ property, settings }) => { + const model = createModel("gpt-4o", settings); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).not.toHaveProperty(property); + }); + + it("should include masking module in orchestration config", async () => { + const masking = { + masking_providers: [ + { + entities: [{ type: "profile-email" }, { type: "profile-phone" }], + method: "anonymization", + type: "sap_data_privacy_integration", + }, + ], + }; + + const model = createModel("gpt-4o", { + masking, + }); + + const prompt = createPrompt("My email is test@example.com"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).toHaveProperty("masking"); + expect(request.masking).toEqual(masking); + }); + + it("should include filtering module in orchestration config", async () => { + const filtering = { + input: { + filters: [ + { + config: { + Hate: 0, + SelfHarm: 0, + Sexual: 0, + Violence: 0, + }, + type: "azure_content_safety", + }, + ], + }, + }; + + const model = createModel("gpt-4o", { + filtering, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).toHaveProperty("filtering"); + expect(request.filtering).toEqual(filtering); + }); + + it("should include both masking and filtering when configured", async () => { + const masking = { + masking_providers: [ + { + entities: [{ type: "profile-person" }], + method: "pseudonymization", + type: "sap_data_privacy_integration", + }, + ], + }; + + const filtering = { + output: { + filters: [ + { + config: { + Hate: 2, + SelfHarm: 2, + Sexual: 2, + Violence: 2, + }, + type: "azure_content_safety", + }, + ], + }, + }; + + const model = createModel("gpt-4o", { + filtering, + masking, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).toHaveProperty("masking"); + expect(request.masking).toEqual(masking); + expect(request).toHaveProperty("filtering"); + expect(request.filtering).toEqual(filtering); + }); + + it("should include grounding module in orchestration config", async () => { + const grounding = { + config: { + filters: [ + { + chunk_ids: [], + data_repositories: ["*"], + document_names: ["product-docs"], + id: "vector-store-1", + }, + ], + metadata_params: ["file_name"], + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }, + type: "document_grounding_service", + }; + + const model = createModel("gpt-4o", { + grounding, + }); + + const prompt = createPrompt("What is SAP AI Core?"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).toHaveProperty("grounding"); + expect(request.grounding).toEqual(grounding); + }); + + it("should include translation module in orchestration config", async () => { + const translation = { + input: { + source_language: "de", + target_language: "en", + }, + output: { + target_language: "de", + }, + }; + + const model = createModel("gpt-4o", { + translation, + }); + + const prompt = createPrompt("Was ist SAP AI Core?"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).toHaveProperty("translation"); + expect(request.translation).toEqual(translation); + }); + + it.each([ + { property: "grounding", settings: { grounding: {} } }, + { property: "translation", settings: { translation: {} } }, + ])("should omit $property when empty object", async ({ property, settings }) => { + const model = createModel("gpt-4o", settings); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).not.toHaveProperty(property); + }); + + it("should include grounding, translation, masking and filtering together", async () => { + const grounding = { + config: { + filters: [ + { + chunk_ids: [], + data_repositories: ["*"], + document_names: [], + id: "vector-store-1", + }, + ], + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }, + type: "document_grounding_service", + }; + + const translation = { + input: { + source_language: "fr", + target_language: "en", + }, + }; + + const masking = { + masking_providers: [ + { + entities: [{ type: "profile-email" }], + method: "anonymization", + type: "sap_data_privacy_integration", + }, + ], + }; + + const filtering = { + input: { + filters: [ + { + config: { + Hate: 0, + SelfHarm: 0, + Sexual: 0, + Violence: 0, + }, + type: "azure_content_safety", + }, + ], + }, + }; + + const model = createModel("gpt-4o", { + filtering, + grounding, + masking, + translation, + }); + + const prompt = createPrompt("Quelle est SAP AI Core?"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + expect(request).toHaveProperty("grounding"); + expect(request.grounding).toEqual(grounding); + expect(request).toHaveProperty("translation"); + expect(request.translation).toEqual(translation); + expect(request).toHaveProperty("masking"); + expect(request.masking).toEqual(masking); + expect(request).toHaveProperty("filtering"); + expect(request.filtering).toEqual(filtering); + }); + + it("should include masking module in stream request body", async () => { + const masking = { + masking_providers: [ + { + entities: [{ type: "profile-email" }, { type: "profile-phone" }], + method: "anonymization", + type: "sap_data_privacy_integration", + }, + ], + }; + + const model = createModel("gpt-4o", { + masking, + }); + + const prompt = createPrompt("My email is test@example.com"); + + const result = await model.doStream({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastStreamRequest(); + expect(request).toHaveProperty("masking"); + expect(request.masking).toEqual(masking); + }); + + it("should include filtering module in stream request body", async () => { + const filtering = { + input: { + filters: [ + { + config: { + Hate: 0, + SelfHarm: 0, + Sexual: 0, + Violence: 0, + }, + type: "azure_content_safety", + }, + ], + }, + }; + + const model = createModel("gpt-4o", { + filtering, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doStream({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastStreamRequest(); + expect(request).toHaveProperty("filtering"); + expect(request.filtering).toEqual(filtering); + }); + + it("should include both masking and filtering in stream request body", async () => { + const masking = { + masking_providers: [ + { + entities: [{ type: "profile-person" }], + method: "pseudonymization", + type: "sap_data_privacy_integration", + }, + ], + }; + + const filtering = { + output: { + filters: [ + { + config: { + Hate: 2, + SelfHarm: 2, + Sexual: 2, + Violence: 2, + }, + type: "azure_content_safety", + }, + ], + }, + }; + + const model = createModel("gpt-4o", { + filtering, + masking, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doStream({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastStreamRequest(); + expect(request).toHaveProperty("masking"); + expect(request.masking).toEqual(masking); + expect(request).toHaveProperty("filtering"); + expect(request.filtering).toEqual(filtering); + }); + + it("should include grounding module in stream request body", async () => { + const grounding = { + config: { + filters: [ + { + chunk_ids: [], + data_repositories: ["*"], + document_names: ["product-docs"], + id: "vector-store-1", + }, + ], + metadata_params: ["file_name"], + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }, + type: "document_grounding_service", + }; + + const model = createModel("gpt-4o", { + grounding, + }); + + const prompt = createPrompt("What is SAP AI Core?"); + + const result = await model.doStream({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastStreamRequest(); + expect(request).toHaveProperty("grounding"); + expect(request.grounding).toEqual(grounding); + }); + + it("should include translation module in stream request body", async () => { + const translation = { + input: { + source_language: "de", + target_language: "en", + }, + output: { + target_language: "de", + }, + }; + + const model = createModel("gpt-4o", { + translation, + }); + + const prompt = createPrompt("Was ist SAP AI Core?"); + + const result = await model.doStream({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastStreamRequest(); + expect(request).toHaveProperty("translation"); + expect(request.translation).toEqual(translation); + }); + + it("should include grounding, translation, masking and filtering together in stream request body", async () => { + const grounding = { + config: { + filters: [ + { + chunk_ids: [], + data_repositories: ["*"], + document_names: [], + id: "vector-store-1", + }, + ], + placeholders: { + input: ["?question"], + output: "groundingOutput", + }, + }, + type: "document_grounding_service", + }; + + const translation = { + input: { + source_language: "fr", + target_language: "en", + }, + }; + + const masking = { + masking_providers: [ + { + entities: [{ type: "profile-email" }], + method: "anonymization", + type: "sap_data_privacy_integration", + }, + ], + }; + + const filtering = { + input: { + filters: [ + { + config: { + Hate: 0, + SelfHarm: 0, + Sexual: 0, + Violence: 0, + }, + type: "azure_content_safety", + }, + ], + }, + }; + + const model = createModel("gpt-4o", { + filtering, + grounding, + masking, + translation, + }); + + const prompt = createPrompt("Quelle est SAP AI Core?"); + + const result = await model.doStream({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastStreamRequest(); + expect(request).toHaveProperty("grounding"); + expect(request.grounding).toEqual(grounding); + expect(request).toHaveProperty("translation"); + expect(request.translation).toEqual(translation); + expect(request).toHaveProperty("masking"); + expect(request.masking).toEqual(masking); + expect(request).toHaveProperty("filtering"); + expect(request.filtering).toEqual(filtering); + }); + }); + + describe("model version", () => { + it("should pass model version to orchestration config", async () => { + const model = createModel("gpt-4o", { + modelVersion: "2024-05-13", + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.version).toBe("2024-05-13"); + }); + + it("should use 'latest' as default version", async () => { + const model = createModel("gpt-4o"); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.version).toBe("latest"); + }); + }); + + describe("model parameters", () => { + it.each([ + { + expectedKey: "temperature", + expectedValue: 0.9, + optionKey: "temperature", + optionValue: 0.9, + settingsKey: "temperature", + settingsValue: 0.5, + testName: "temperature", + }, + { + camelCaseKey: "maxTokens", + expectedKey: "max_tokens", + expectedValue: 1000, + optionKey: "maxOutputTokens", + optionValue: 1000, + settingsKey: "maxTokens", + settingsValue: 500, + testName: "maxOutputTokens", + }, + { + camelCaseKey: "topP", + expectedKey: "top_p", + expectedValue: 0.9, + optionKey: "topP", + optionValue: 0.9, + settingsKey: "topP", + settingsValue: 0.5, + testName: "topP", + }, + { + camelCaseKey: "topK", + expectedKey: "top_k", + expectedValue: 40, + optionKey: "topK", + optionValue: 40, + settingsKey: "topK", + settingsValue: 20, + testName: "topK", + }, + ])( + "should prefer options.$testName over settings.modelParams.$settingsKey", + async ({ + camelCaseKey, + expectedKey, + expectedValue, + optionKey, + optionValue, + settingsKey, + settingsValue, + }) => { + const model = createModel("gpt-4o", { + modelParams: { + [settingsKey]: settingsValue, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ + [optionKey]: optionValue, + prompt, + }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.[expectedKey]).toBe(expectedValue); + + if (camelCaseKey && camelCaseKey !== expectedKey) { + expect(request.model?.params?.[camelCaseKey]).toBeUndefined(); + } + }, + ); + + it.each([ + { + camelCaseKey: "topP", + expectedKey: "top_p", + expectedValue: 0.9, + paramName: "topP", + paramValue: 0.9, + }, + { + camelCaseKey: "topK", + expectedKey: "top_k", + expectedValue: 40, + paramName: "topK", + paramValue: 40, + }, + { + camelCaseKey: "frequencyPenalty", + expectedKey: "frequency_penalty", + expectedValue: 0.5, + paramName: "frequencyPenalty", + paramValue: 0.5, + }, + { + camelCaseKey: "presencePenalty", + expectedKey: "presence_penalty", + expectedValue: 0.3, + paramName: "presencePenalty", + paramValue: 0.3, + }, + { + camelCaseKey: "stopSequences", + expectedKey: "stop", + expectedValue: ["END", "STOP"], + paramName: "stopSequences", + paramValue: ["END", "STOP"], + }, + { + camelCaseKey: "seed", + expectedKey: "seed", + expectedValue: 42, + paramName: "seed", + paramValue: 42, + }, + ])( + "should pass $paramName from options to model params", + async ({ camelCaseKey, expectedKey, expectedValue, paramName, paramValue }) => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ + [paramName]: paramValue, + prompt, + }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.[expectedKey]).toEqual(expectedValue); + + if (camelCaseKey !== expectedKey) { + expect(request.model?.params?.[camelCaseKey]).toBeUndefined(); + } + }, + ); + }); + + describe("model-specific behavior", () => { + it.each([ + { modelId: "amazon--nova-pro", vendor: "Amazon" }, + { modelId: "anthropic--claude-3.5-sonnet", vendor: "Anthropic" }, + ])("should disable n parameter for $vendor models", async ({ modelId }) => { + const model = createModel(modelId, { + modelParams: { n: 2 }, + }); + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.n).toBeUndefined(); + }); + }); + + describe("unknown parameter preservation", () => { + it("should preserve unknown parameters from settings.modelParams", async () => { + const model = createModel("gpt-4o", { + modelParams: { + customParam: "custom-value", + maxTokens: 100, + temperature: 0.7, + topP: 0.8, + unknownField: 123, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.7); + expect(request.model?.params?.customParam).toBe("custom-value"); + expect(request.model?.params?.unknownField).toBe(123); + + expect(request.model?.params?.max_tokens).toBe(100); + expect(request.model?.params?.maxTokens).toBeUndefined(); + expect(request.model?.params?.top_p).toBe(0.8); + expect(request.model?.params?.topP).toBeUndefined(); + }); + + it("should preserve unknown parameters from providerOptions", async () => { + const model = createModel("gpt-4o"); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ + prompt, + providerOptions: { + "sap-ai": { + modelParams: { + customProviderParam: "provider-value", + frequencyPenalty: 0.7, + presencePenalty: 0.2, + specialField: true, + temperature: 0.5, + }, + }, + }, + }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.5); + expect(request.model?.params?.customProviderParam).toBe("provider-value"); + expect(request.model?.params?.specialField).toBe(true); + + expect(request.model?.params?.frequency_penalty).toBe(0.7); + expect(request.model?.params?.frequencyPenalty).toBeUndefined(); + expect(request.model?.params?.presence_penalty).toBe(0.2); + expect(request.model?.params?.presencePenalty).toBeUndefined(); + }); + + it("should merge unknown parameters from settings and providerOptions", async () => { + const model = createModel("gpt-4o", { + modelParams: { + fromSettings: "settings-value", + maxTokens: 800, + sharedParam: "from-settings", + temperature: 0.3, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ + prompt, + providerOptions: { + "sap-ai": { + modelParams: { + fromProvider: "provider-value", + sharedParam: "from-provider", + topP: 0.95, + }, + }, + }, + }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.3); + expect(request.model?.params?.fromSettings).toBe("settings-value"); + expect(request.model?.params?.fromProvider).toBe("provider-value"); + expect(request.model?.params?.sharedParam).toBe("from-provider"); + + expect(request.model?.params?.max_tokens).toBe(800); + expect(request.model?.params?.maxTokens).toBeUndefined(); + expect(request.model?.params?.top_p).toBe(0.95); + expect(request.model?.params?.topP).toBeUndefined(); + }); + + it("should deep merge nested objects in modelParams", async () => { + const model = createModel("gpt-4o", { + modelParams: { + nested: { + a: 1, + b: { + c: 2, + d: 3, + }, + }, + temperature: 0.5, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ + prompt, + providerOptions: { + "sap-ai": { + modelParams: { + nested: { + b: { + d: 4, + e: 5, + }, + f: 6, + }, + }, + }, + }, + }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.5); + expect(request.model?.params?.nested).toEqual({ + a: 1, + b: { + c: 2, + d: 4, + e: 5, + }, + f: 6, + }); + }); + + it("should allow AI SDK standard options to override unknown params", async () => { + const model = createModel("gpt-4o", { + modelParams: { + customParam: "custom-value", + temperature: 0.3, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ + maxOutputTokens: 500, + prompt, + temperature: 0.8, + }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.8); + expect(request.model?.params?.max_tokens).toBe(500); + expect(request.model?.params?.maxTokens).toBeUndefined(); + expect(request.model?.params?.customParam).toBe("custom-value"); + }); + + it("should preserve complex unknown parameter types", async () => { + const model = createModel("gpt-4o", { + modelParams: { + arrayParam: [1, 2, 3], + nestedObject: { + foo: "bar", + nested: { deep: true }, + }, + nullParam: null, + temperature: 0.5, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.5); + expect(request.model?.params?.arrayParam).toEqual([1, 2, 3]); + expect(request.model?.params?.nestedObject).toEqual({ + foo: "bar", + nested: { deep: true }, + }); + expect(request.model?.params?.nullParam).toBe(null); + }); + + it("should preserve unknown params even when n is removed for Amazon models", async () => { + const model = createModel("amazon--nova-pro", { + modelParams: { + customAmazonParam: "amazon-value", + n: 2, + temperature: 0.7, + }, + }); + + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + expect(request.model?.params?.temperature).toBe(0.7); + expect(request.model?.params?.n).toBeUndefined(); + expect(request.model?.params?.customAmazonParam).toBe("amazon-value"); + }); + }); + + describe("warnings", () => { + it("should warn when toolChoice is not 'auto'", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const tools: LanguageModelV3FunctionTool[] = [ + { + description: "A test tool", + inputSchema: { properties: {}, required: [], type: "object" }, + name: "test_tool", + type: "function", + }, + ]; + + const result = await model.doGenerate({ + prompt, + toolChoice: { type: "required" }, + tools, + }); + + expect(result.warnings).toContainEqual( + expect.objectContaining({ + feature: "toolChoice", + type: "unsupported", + }), + ); + }); + + it("should not warn when toolChoice is 'auto'", async () => { + const model = createModel(); + const prompt = createPrompt("Hello"); + + const tools: LanguageModelV3FunctionTool[] = [ + { + description: "A test tool", + inputSchema: { properties: {}, required: [], type: "object" }, + name: "test_tool", + type: "function", + }, + ]; + + const result = await model.doGenerate({ + prompt, + toolChoice: { type: "auto" }, + tools, + }); + + const toolChoiceWarnings = result.warnings.filter( + (w) => + w.type === "unsupported" && + (w as unknown as { feature?: string }).feature === "toolChoice", + ); + expect(toolChoiceWarnings).toHaveLength(0); + }); + + it("should emit a best-effort warning for responseFormat json", async () => { + const model = createModel(); + const prompt = createPrompt("Return JSON"); + + const result = await model.doGenerate({ + prompt, + responseFormat: { type: "json" }, + }); + const warnings = result.warnings as { message?: string; type: string }[]; + + expect(warnings.length).toBeGreaterThan(0); + expectWarningMessageContains(warnings, "responseFormat JSON mode"); + }); + + it("should emit a best-effort warning for settings.responseFormat json", async () => { + const model = createModel("gpt-4o", { + responseFormat: { + json_schema: { + name: "test", + schema: { type: "object" }, + }, + type: "json_schema", + }, + }); + const prompt = createPrompt("Return JSON"); + + const result = await model.doGenerate({ prompt }); + const warnings = result.warnings as { message?: string; type: string }[]; + + expect(warnings.length).toBeGreaterThan(0); + expectWarningMessageContains(warnings, "responseFormat JSON mode"); + }); + + it("should not emit responseFormat warning when responseFormat is text", async () => { + const model = createModel("gpt-4o", { + responseFormat: { type: "text" }, + }); + const prompt = createPrompt("Hello"); + + const result = await model.doGenerate({ prompt }); + const warnings = result.warnings as { message?: string; type: string }[]; + + const hasResponseFormatWarning = warnings.some( + (w) => typeof w.message === "string" && w.message.includes("responseFormat JSON mode"), + ); + expect(hasResponseFormatWarning).toBe(false); + }); + }); + + describe("tools", () => { + it("should use tools from settings when provided", async () => { + const model = createModel("gpt-4o", { + tools: [ + { + function: { + description: "A custom tool from settings", + name: "custom_tool", + parameters: { + properties: { + input: { type: "string" }, + }, + required: ["input"], + type: "object", + }, + }, + type: "function", + }, + ], + }); + + const prompt = createPrompt("Use a tool"); + + const result = await model.doGenerate({ prompt }); + + expectRequestBodyHasMessages(result); + + const request = await getLastChatCompletionRequest(); + + const tools = Array.isArray(request.tools) ? (request.tools as unknown[]) : undefined; + + expect(tools).toBeDefined(); + if (tools) { + expect(tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "function", + }), + ]), + ); + + const customTool = tools.find( + (tool): tool is { function?: { name?: string }; type?: string } => + typeof tool === "object" && + tool !== null && + (tool as { type?: unknown }).type === "function" && + typeof (tool as { function?: { name?: unknown } }).function?.name === "string" && + (tool as { function?: { name?: string } }).function?.name === "custom_tool", + ); + + expect(customTool).toBeDefined(); + } + }); + + it.each([ + { + description: "Tool with array schema", + inputSchema: { items: { type: "string" }, type: "array" }, + testName: "coerce non-object schema type to object (array)", + toolName: "array_tool", + }, + { + description: "Tool with string schema", + inputSchema: { type: "string" }, + testName: "handle tool with string type schema", + toolName: "string_tool", + }, + { + description: "Tool with empty properties", + inputSchema: { properties: {}, type: "object" }, + testName: "handle tool with schema that has no properties", + toolName: "empty_props_tool", + }, + { + description: "Tool without schema", + inputSchema: undefined as unknown as Record, + testName: "handle tool with undefined inputSchema", + toolName: "no_schema_tool", + }, + ])("should $testName", async ({ description, inputSchema, toolName }) => { + const model = createModel(); + const prompt = createPrompt("Use tool"); + + const tools: LanguageModelV3FunctionTool[] = [ + { + description, + inputSchema, + name: toolName, + type: "function", + }, + ]; + + const result = await model.doGenerate({ prompt, tools }); + + expectRequestBodyHasMessages(result); + }); + }); + }); +}); diff --git a/src/sap-ai-language-model.ts b/src/sap-ai-language-model.ts new file mode 100644 index 0000000..5fd7bce --- /dev/null +++ b/src/sap-ai-language-model.ts @@ -0,0 +1,1047 @@ +/** + * SAP AI Language Model - Vercel AI SDK LanguageModelV3 implementation for SAP AI Core Orchestration. + * + * This is the main implementation containing all business logic for SAP AI Core integration. + */ +import type { DeploymentIdConfig, ResourceGroupConfig } from "@sap-ai-sdk/ai-api/internal.js"; +import type { LlmModelParams } from "@sap-ai-sdk/orchestration"; +import type { Template } from "@sap-ai-sdk/orchestration/dist/client/api/schema/template.js"; +import type { HttpDestinationOrFetchOptions } from "@sap-cloud-sdk/connectivity"; + +import { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3FunctionTool, + LanguageModelV3GenerateResult, + LanguageModelV3StreamPart, + LanguageModelV3StreamResult, + SharedV3Warning, +} from "@ai-sdk/provider"; +import { parseProviderOptions } from "@ai-sdk/provider-utils"; +import { + ChatCompletionTool, + ChatMessage, + OrchestrationClient, + OrchestrationModuleConfig, +} from "@sap-ai-sdk/orchestration"; +import { z, type ZodType } from "zod"; + +import { deepMerge } from "./deep-merge.js"; + +/** + * @internal + */ +interface FunctionToolWithParameters extends LanguageModelV3FunctionTool { + readonly parameters?: unknown; +} + +import { convertToSAPMessages } from "./convert-to-sap-messages"; +import { convertToAISDKError, normalizeHeaders } from "./sap-ai-error"; +import { + getProviderName, + sapAILanguageModelProviderOptions, + validateModelParamsSettings, + validateModelParamsWithWarnings, +} from "./sap-ai-provider-options"; +import { SAPAIModelId, SAPAISettings } from "./sap-ai-settings"; +import { VERSION } from "./version.js"; + +/** + * Parameter mapping for AI SDK options → SAP model params. + * @internal + */ +interface ParamMapping { + /** camelCase key in modelParams to read from and remove (e.g., 'maxTokens', 'topP') */ + readonly camelCaseKey?: string; + /** AI SDK option key (e.g., 'maxOutputTokens', 'topP') */ + readonly optionKey?: string; + /** Output key for SAP API (e.g., 'max_tokens', 'top_p') */ + readonly outputKey: string; +} + +/** + * Internal configuration for SAP AI Language Model. + * @internal + */ +interface SAPAILanguageModelConfig { + readonly deploymentConfig: DeploymentIdConfig | ResourceGroupConfig; + readonly destination?: HttpDestinationOrFetchOptions; + readonly provider: string; +} + +/** + * @internal + */ +type SAPModelParams = LlmModelParams & { + parallel_tool_calls?: boolean; + seed?: number; + stop?: string[]; + top_k?: number; +}; + +type SAPResponseFormat = Template["response_format"]; + +/** + * @internal + */ +type SAPToolParameters = Record & { + type: "object"; +}; + +/** + * Parameter mappings for override resolution and camelCase conversion. + * @internal + */ +const PARAM_MAPPINGS: readonly ParamMapping[] = [ + { camelCaseKey: "maxTokens", optionKey: "maxOutputTokens", outputKey: "max_tokens" }, + { camelCaseKey: "temperature", optionKey: "temperature", outputKey: "temperature" }, + { camelCaseKey: "topP", optionKey: "topP", outputKey: "top_p" }, + { camelCaseKey: "topK", optionKey: "topK", outputKey: "top_k" }, + { + camelCaseKey: "frequencyPenalty", + optionKey: "frequencyPenalty", + outputKey: "frequency_penalty", + }, + { camelCaseKey: "presencePenalty", optionKey: "presencePenalty", outputKey: "presence_penalty" }, + { camelCaseKey: "seed", optionKey: "seed", outputKey: "seed" }, + { camelCaseKey: "parallel_tool_calls", outputKey: "parallel_tool_calls" }, +] as const; + +/** + * @internal + */ +class StreamIdGenerator { + generateResponseId(): string { + return crypto.randomUUID(); + } + + generateTextBlockId(): string { + return crypto.randomUUID(); + } +} + +/** + * SAP AI Language Model implementing Vercel AI SDK LanguageModelV3. + * + * Features: text generation, tool calling, multi-modal input, data masking, content filtering. + * Supports: Azure OpenAI, Google Vertex AI, AWS Bedrock, AI Core open source models. + */ +export class SAPAILanguageModel implements LanguageModelV3 { + /** The model identifier. */ + readonly modelId: SAPAIModelId; + /** The Vercel AI SDK specification version. */ + readonly specificationVersion = "v3"; + + /** Whether the model supports image URLs in prompts. */ + readonly supportsImageUrls: boolean = true; + /** Whether the model supports generating multiple completions. */ + readonly supportsMultipleCompletions: boolean = true; + /** Whether the model supports parallel tool calls. */ + readonly supportsParallelToolCalls: boolean = true; + /** Whether the model supports streaming responses. */ + readonly supportsStreaming: boolean = true; + /** Whether the model supports structured JSON outputs. */ + readonly supportsStructuredOutputs: boolean = true; + /** Whether the model supports tool/function calling. */ + readonly supportsToolCalls: boolean = true; + + /** + * Gets the provider identifier string. + * @returns The provider identifier. + */ + get provider(): string { + return this.config.provider; + } + + /** + * Gets the supported URL patterns for image input. + * @returns A mapping of MIME type patterns to URL regex patterns. + */ + get supportedUrls(): Record { + return { + "image/*": [/^https:\/\/.+$/i, /^data:image\/.*$/], + }; + } + + /** @internal */ + private readonly config: SAPAILanguageModelConfig; + + /** @internal */ + private readonly settings: SAPAISettings; + + /** + * Creates a new SAP AI Language Model instance. + * + * This is the main implementation that handles all SAP AI Core orchestration logic. + * @param modelId - The model identifier (e.g., 'gpt-4o', 'claude-3-5-sonnet', 'gemini-2.0-flash'). + * @param settings - Model configuration settings (temperature, max tokens, filtering, etc.). + * @param config - SAP AI Core deployment and destination configuration. + * @internal + */ + constructor(modelId: SAPAIModelId, settings: SAPAISettings, config: SAPAILanguageModelConfig) { + if (settings.modelParams) { + validateModelParamsSettings(settings.modelParams); + } + this.settings = settings; + this.config = config; + this.modelId = modelId; + } + + /** + * Generates a single completion (non-streaming). + * + * Builds orchestration configuration, converts messages, validates parameters, + * calls SAP AI SDK, and processes the response. + * Supports request cancellation via AbortSignal at the HTTP transport layer. + * @param options - The Vercel AI SDK V3 generation call options. + * @returns The generation result with content, usage, warnings, and provider metadata. + */ + async doGenerate(options: LanguageModelV3CallOptions): Promise { + try { + const { messages, orchestrationConfig, warnings } = + await this.buildOrchestrationConfig(options); + + const client = this.createClient(orchestrationConfig); + + const requestBody = this.buildRequestBody(messages, orchestrationConfig); + + const response = await client.chatCompletion( + requestBody, + options.abortSignal ? { signal: options.abortSignal } : undefined, + ); + const responseHeaders = normalizeHeaders(response.rawResponse.headers); + + const content: LanguageModelV3Content[] = []; + + const textContent = response.getContent(); + if (textContent) { + content.push({ + text: textContent, + type: "text", + }); + } + + const toolCalls = response.getToolCalls(); + if (toolCalls) { + for (const toolCall of toolCalls) { + content.push({ + input: toolCall.function.arguments, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + type: "tool-call", + }); + } + } + + const tokenUsage = response.getTokenUsage(); + + const finishReasonRaw = response.getFinishReason(); + const finishReason = mapFinishReason(finishReasonRaw); + + const rawResponseBody = { + content: textContent, + finishReason: finishReasonRaw, + tokenUsage, + toolCalls, + }; + + const providerName = getProviderName(this.config.provider); + + return { + content, + finishReason, + providerMetadata: { + [providerName]: { + finishReason: finishReasonRaw ?? "unknown", + finishReasonMapped: finishReason, + ...(typeof responseHeaders?.["x-request-id"] === "string" + ? { requestId: responseHeaders["x-request-id"] } + : {}), + version: VERSION, + }, + }, + request: { + body: requestBody as unknown, + }, + response: { + body: rawResponseBody, + headers: responseHeaders, + modelId: this.modelId, + timestamp: new Date(), + }, + usage: { + inputTokens: { + cacheRead: undefined, + cacheWrite: undefined, + noCache: tokenUsage.prompt_tokens, + total: tokenUsage.prompt_tokens, + }, + outputTokens: { + reasoning: undefined, + text: tokenUsage.completion_tokens, + total: tokenUsage.completion_tokens, + }, + }, + warnings, + }; + } catch (error) { + throw convertToAISDKError(error, { + operation: "doGenerate", + requestBody: createAISDKRequestBodySummary(options), + url: "sap-ai:orchestration", + }); + } + } + + /** + * Generates a streaming completion. + * + * Builds orchestration configuration, creates streaming client, and transforms + * the stream with proper event handling (text blocks, tool calls, finish reason). + * Supports request cancellation via AbortSignal at the HTTP transport layer. + * @param options - The Vercel AI SDK V3 generation call options. + * @returns A stream result with async iterable stream parts. + */ + async doStream(options: LanguageModelV3CallOptions): Promise { + try { + const { messages, orchestrationConfig, warnings } = + await this.buildOrchestrationConfig(options); + + const client = this.createClient(orchestrationConfig); + + const requestBody = this.buildRequestBody(messages, orchestrationConfig); + + const streamResponse = await client.stream(requestBody, options.abortSignal, { + promptTemplating: { include_usage: true }, + }); + + const idGenerator = new StreamIdGenerator(); + + // Client-generated UUID; TODO: use backend x-request-id when SDK exposes rawResponse + const responseId = idGenerator.generateResponseId(); + + let textBlockId: null | string = null; + + const streamState = { + activeText: false, + finishReason: { + raw: undefined, + unified: "other" as const, + } as LanguageModelV3FinishReason, + isFirstChunk: true, + usage: { + inputTokens: { + cacheRead: undefined, + cacheWrite: undefined, + noCache: undefined as number | undefined, + total: undefined as number | undefined, + }, + outputTokens: { + reasoning: undefined, + text: undefined as number | undefined, + total: undefined as number | undefined, + }, + }, + }; + + const toolCallsInProgress = new Map< + number, + { + arguments: string; + didEmitCall: boolean; + didEmitInputStart: boolean; + id: string; + toolName?: string; + } + >(); + + const sdkStream = streamResponse.stream; + const modelId = this.modelId; + const providerName = getProviderName(this.config.provider); + + const warningsSnapshot = [...warnings]; + + const transformedStream = new ReadableStream({ + cancel(reason) { + if (reason) { + console.debug("SAP AI stream cancelled:", reason); + } + }, + async start(controller) { + controller.enqueue({ + type: "stream-start", + warnings: warningsSnapshot, + }); + + try { + for await (const chunk of sdkStream) { + if (options.includeRawChunks) { + controller.enqueue({ + rawValue: (chunk as { _data?: unknown })._data ?? chunk, + type: "raw", + }); + } + + if (streamState.isFirstChunk) { + streamState.isFirstChunk = false; + controller.enqueue({ + id: responseId, + modelId, + timestamp: new Date(), + type: "response-metadata", + }); + } + + const deltaToolCalls = chunk.getDeltaToolCalls(); + if (Array.isArray(deltaToolCalls) && deltaToolCalls.length > 0) { + streamState.finishReason = { + raw: undefined, + unified: "tool-calls", + }; + } + + const deltaContent = chunk.getDeltaContent(); + if ( + typeof deltaContent === "string" && + deltaContent.length > 0 && + streamState.finishReason.unified !== "tool-calls" + ) { + if (!streamState.activeText) { + textBlockId = idGenerator.generateTextBlockId(); + controller.enqueue({ id: textBlockId, type: "text-start" }); + streamState.activeText = true; + } + if (textBlockId) { + controller.enqueue({ + delta: deltaContent, + id: textBlockId, + type: "text-delta", + }); + } + } + + if (Array.isArray(deltaToolCalls) && deltaToolCalls.length > 0) { + for (const toolCallChunk of deltaToolCalls) { + const index = toolCallChunk.index; + if (typeof index !== "number" || !Number.isFinite(index)) { + continue; + } + + if (!toolCallsInProgress.has(index)) { + toolCallsInProgress.set(index, { + arguments: "", + didEmitCall: false, + didEmitInputStart: false, + id: toolCallChunk.id ?? `tool_${String(index)}`, + toolName: toolCallChunk.function?.name, + }); + } + + const tc = toolCallsInProgress.get(index); + if (!tc) continue; + + if (toolCallChunk.id) { + tc.id = toolCallChunk.id; + } + + const nextToolName = toolCallChunk.function?.name; + if (typeof nextToolName === "string" && nextToolName.length > 0) { + tc.toolName = nextToolName; + } + + if (!tc.didEmitInputStart && tc.toolName) { + tc.didEmitInputStart = true; + controller.enqueue({ + id: tc.id, + toolName: tc.toolName, + type: "tool-input-start", + }); + } + + const argumentsDelta = toolCallChunk.function?.arguments; + if (typeof argumentsDelta === "string" && argumentsDelta.length > 0) { + tc.arguments += argumentsDelta; + + if (tc.didEmitInputStart) { + controller.enqueue({ + delta: argumentsDelta, + id: tc.id, + type: "tool-input-delta", + }); + } + } + } + } + + const chunkFinishReason = chunk.getFinishReason(); + if (chunkFinishReason) { + streamState.finishReason = mapFinishReason(chunkFinishReason); + + if (streamState.finishReason.unified === "tool-calls") { + const toolCalls = Array.from(toolCallsInProgress.values()); + for (const tc of toolCalls) { + if (tc.didEmitCall) { + continue; + } + if (!tc.didEmitInputStart) { + tc.didEmitInputStart = true; + controller.enqueue({ + id: tc.id, + toolName: tc.toolName ?? "", + type: "tool-input-start", + }); + } + + tc.didEmitCall = true; + controller.enqueue({ id: tc.id, type: "tool-input-end" }); + controller.enqueue({ + input: tc.arguments, + toolCallId: tc.id, + toolName: tc.toolName ?? "", + type: "tool-call", + }); + } + + if (streamState.activeText && textBlockId) { + controller.enqueue({ id: textBlockId, type: "text-end" }); + streamState.activeText = false; + } + } + } + } + + const toolCalls = Array.from(toolCallsInProgress.values()); + let didEmitAnyToolCalls = false; + + for (const tc of toolCalls) { + if (tc.didEmitCall) { + continue; + } + + if (!tc.didEmitInputStart) { + tc.didEmitInputStart = true; + controller.enqueue({ + id: tc.id, + toolName: tc.toolName ?? "", + type: "tool-input-start", + }); + } + + didEmitAnyToolCalls = true; + tc.didEmitCall = true; + controller.enqueue({ id: tc.id, type: "tool-input-end" }); + controller.enqueue({ + input: tc.arguments, + toolCallId: tc.id, + toolName: tc.toolName ?? "", + type: "tool-call", + }); + } + + if (streamState.activeText && textBlockId) { + controller.enqueue({ id: textBlockId, type: "text-end" }); + } + + const finalFinishReason = streamResponse.getFinishReason(); + if (finalFinishReason) { + streamState.finishReason = mapFinishReason(finalFinishReason); + } else if (didEmitAnyToolCalls) { + streamState.finishReason = { + raw: undefined, + unified: "tool-calls", + }; + } + + const finalUsage = streamResponse.getTokenUsage(); + if (finalUsage) { + streamState.usage.inputTokens.total = finalUsage.prompt_tokens; + streamState.usage.inputTokens.noCache = finalUsage.prompt_tokens; + streamState.usage.outputTokens.total = finalUsage.completion_tokens; + streamState.usage.outputTokens.text = finalUsage.completion_tokens; + } + + controller.enqueue({ + finishReason: streamState.finishReason, + providerMetadata: { + [providerName]: { + finishReason: streamState.finishReason.raw, + responseId, + version: VERSION, + }, + }, + type: "finish", + usage: streamState.usage, + }); + + controller.close(); + } catch (error) { + const aiError = convertToAISDKError(error, { + operation: "doStream", + requestBody: createAISDKRequestBodySummary(options), + url: "sap-ai:orchestration", + }); + controller.enqueue({ + error: aiError instanceof Error ? aiError : new Error(String(aiError)), + type: "error", + }); + controller.close(); + } + }, + }); + + return { + request: { + body: requestBody as unknown, + }, + stream: transformedStream, + }; + } catch (error) { + throw convertToAISDKError(error, { + operation: "doStream", + requestBody: createAISDKRequestBodySummary(options), + url: "sap-ai:orchestration", + }); + } + } + + /** + * Checks if a URL is supported for image input (HTTPS or data:image/*). + * @param url - The URL to check. + * @returns True if the URL is supported for image input. + */ + supportsUrl(url: URL): boolean { + if (url.protocol === "https:") return true; + if (url.protocol === "data:") { + return /^data:image\//i.test(url.href); + } + return false; + } + + /** + * Builds the SAP AI SDK orchestration configuration from Vercel AI SDK call options. + * @param options - The Vercel AI SDK language model call options. + * @returns The SAP AI SDK orchestration configuration, messages, and warnings. + * @internal + */ + private async buildOrchestrationConfig(options: LanguageModelV3CallOptions): Promise<{ + messages: ChatMessage[]; + orchestrationConfig: OrchestrationModuleConfig; + warnings: SharedV3Warning[]; + }> { + const providerName = getProviderName(this.config.provider); + const sapOptions = await parseProviderOptions({ + provider: providerName, + providerOptions: options.providerOptions, + schema: sapAILanguageModelProviderOptions, + }); + + const warnings: SharedV3Warning[] = []; + + const messages = convertToSAPMessages(options.prompt, { + includeReasoning: sapOptions?.includeReasoning ?? this.settings.includeReasoning ?? false, + }); + + let tools: ChatCompletionTool[] | undefined; + + const settingsTools = this.settings.tools; + const optionsTools = options.tools; + + const shouldUseSettingsTools = + settingsTools && settingsTools.length > 0 && (!optionsTools || optionsTools.length === 0); + + const shouldUseOptionsTools = !!(optionsTools && optionsTools.length > 0); + + if (settingsTools && settingsTools.length > 0 && optionsTools && optionsTools.length > 0) { + warnings.push({ + message: + "Both settings.tools and call options.tools were provided; preferring call options.tools.", + type: "other", + }); + } + + if (shouldUseSettingsTools) { + tools = settingsTools; + } else { + const availableTools = shouldUseOptionsTools ? optionsTools : undefined; + + tools = availableTools + ?.map((tool): ChatCompletionTool | null => { + if (tool.type === "function") { + const inputSchema = tool.inputSchema as Record | undefined; + const toolWithParams = tool as FunctionToolWithParameters; + + let parameters: SAPToolParameters; + + if (toolWithParams.parameters && isZodSchema(toolWithParams.parameters)) { + try { + const jsonSchema = z.toJSONSchema(toolWithParams.parameters); + const schemaRecord = jsonSchema as Record; + delete schemaRecord.$schema; + parameters = buildSAPToolParameters(schemaRecord); + } catch (error) { + warnings.push({ + details: `Failed to convert tool Zod schema: ${error instanceof Error ? error.message : String(error)}. Falling back to empty object schema.`, + feature: `tool schema conversion for ${tool.name}`, + type: "unsupported", + }); + parameters = buildSAPToolParameters({}); + } + } else if (inputSchema && Object.keys(inputSchema).length > 0) { + const hasProperties = + inputSchema.properties && + typeof inputSchema.properties === "object" && + Object.keys(inputSchema.properties).length > 0; + + if (hasProperties) { + parameters = buildSAPToolParameters(inputSchema); + } else { + parameters = buildSAPToolParameters({}); + } + } else { + parameters = buildSAPToolParameters({}); + } + + return { + function: { + description: tool.description, + name: tool.name, + parameters, + }, + type: "function", + }; + } else { + warnings.push({ + details: "Only 'function' tool type is supported.", + feature: `tool type for ${tool.name}`, + type: "unsupported", + }); + return null; + } + }) + .filter((t): t is ChatCompletionTool => t !== null); + } + + const supportsN = + !this.modelId.startsWith("amazon--") && !this.modelId.startsWith("anthropic--"); + + const modelParams: SAPModelParams = deepMerge( + this.settings.modelParams ?? {}, + sapOptions?.modelParams ?? {}, + ); + + applyParameterOverrides( + modelParams, + options as Record, + sapOptions?.modelParams as Record | undefined, + this.settings.modelParams as Record | undefined, + ); + + if (options.stopSequences && options.stopSequences.length > 0) { + modelParams.stop = options.stopSequences; + } + + if (supportsN) { + const nValue = sapOptions?.modelParams?.n ?? this.settings.modelParams?.n; + if (nValue !== undefined) { + modelParams.n = nValue; + } + } else { + delete modelParams.n; + } + + validateModelParamsWithWarnings( + { + frequencyPenalty: options.frequencyPenalty, + maxTokens: options.maxOutputTokens, + presencePenalty: options.presencePenalty, + temperature: options.temperature, + topP: options.topP, + }, + warnings, + ); + + if (options.toolChoice && options.toolChoice.type !== "auto") { + warnings.push({ + details: `SAP AI SDK does not support toolChoice '${options.toolChoice.type}'. Using default 'auto' behavior.`, + feature: "toolChoice", + type: "unsupported", + }); + } + + let responseFormat: SAPResponseFormat | undefined; + if (options.responseFormat?.type === "json") { + responseFormat = options.responseFormat.schema + ? { + json_schema: { + description: options.responseFormat.description, + name: options.responseFormat.name ?? "response", + schema: options.responseFormat.schema as Record, + strict: null, + }, + type: "json_schema" as const, + } + : { type: "json_object" as const }; + } else if (this.settings.responseFormat) { + responseFormat = this.settings.responseFormat as SAPResponseFormat; + } + + if (responseFormat && responseFormat.type !== "text") { + warnings.push({ + message: + "responseFormat JSON mode is forwarded to the underlying model; support and schema adherence depend on the model/deployment.", + type: "other", + }); + } + + const orchestrationConfig: OrchestrationModuleConfig = { + promptTemplating: { + model: { + name: this.modelId, + params: modelParams, + version: this.settings.modelVersion ?? "latest", + }, + prompt: { + template: [], + tools: tools && tools.length > 0 ? tools : undefined, + ...(responseFormat ? { response_format: responseFormat } : {}), + }, + }, + ...(this.settings.masking && Object.keys(this.settings.masking).length > 0 + ? { masking: this.settings.masking } + : {}), + ...(this.settings.filtering && Object.keys(this.settings.filtering).length > 0 + ? { filtering: this.settings.filtering } + : {}), + ...(this.settings.grounding && Object.keys(this.settings.grounding).length > 0 + ? { grounding: this.settings.grounding } + : {}), + ...(this.settings.translation && Object.keys(this.settings.translation).length > 0 + ? { translation: this.settings.translation } + : {}), + }; + + return { messages, orchestrationConfig, warnings }; + } + + /** + * Builds the request body for SAP AI SDK chat completion or streaming. + * @param messages - The chat messages to send. + * @param orchestrationConfig - The orchestration configuration. + * @returns The request body object. + * @internal + */ + private buildRequestBody( + messages: ChatMessage[], + orchestrationConfig: OrchestrationModuleConfig, + ): Record { + const promptTemplating = orchestrationConfig.promptTemplating as unknown as { + prompt: { response_format?: unknown; tools?: unknown }; + }; + + return { + messages, + model: { + ...orchestrationConfig.promptTemplating.model, + }, + ...(promptTemplating.prompt.tools ? { tools: promptTemplating.prompt.tools } : {}), + ...(promptTemplating.prompt.response_format + ? { response_format: promptTemplating.prompt.response_format } + : {}), + ...(orchestrationConfig.masking && Object.keys(orchestrationConfig.masking).length > 0 + ? { masking: orchestrationConfig.masking } + : {}), + ...(orchestrationConfig.filtering && Object.keys(orchestrationConfig.filtering).length > 0 + ? { filtering: orchestrationConfig.filtering } + : {}), + ...(orchestrationConfig.grounding && Object.keys(orchestrationConfig.grounding).length > 0 + ? { grounding: orchestrationConfig.grounding } + : {}), + ...(orchestrationConfig.translation && Object.keys(orchestrationConfig.translation).length > 0 + ? { translation: orchestrationConfig.translation } + : {}), + }; + } + + /** + * Creates an SAP AI SDK OrchestrationClient with the given configuration. + * @param config - The SAP AI SDK orchestration module configuration. + * @returns A configured SAP AI SDK orchestration client. + * @internal + */ + private createClient(config: OrchestrationModuleConfig): OrchestrationClient { + return new OrchestrationClient(config, this.config.deploymentConfig, this.config.destination); + } +} + +/** + * Applies parameter overrides from AI SDK options and modelParams, with camelCase → snake_case conversion. + * @param modelParams - The model parameters object (modified in place). + * @param options - AI SDK language model call options. + * @param sapModelParams - SAP-specific modelParams from providerOptions. + * @param settingsModelParams - modelParams from provider settings. + * @internal + */ +function applyParameterOverrides( + modelParams: SAPModelParams, + options: Record, + sapModelParams: Record | undefined, + settingsModelParams: Record | undefined, +): void { + const params = modelParams as Record; + + for (const mapping of PARAM_MAPPINGS) { + const value = + (mapping.optionKey ? options[mapping.optionKey] : undefined) ?? + (mapping.camelCaseKey ? sapModelParams?.[mapping.camelCaseKey] : undefined) ?? + (mapping.camelCaseKey ? settingsModelParams?.[mapping.camelCaseKey] : undefined); + + if (value !== undefined) { + params[mapping.outputKey] = value; + } + + if (mapping.camelCaseKey && mapping.camelCaseKey !== mapping.outputKey) { + Reflect.deleteProperty(params, mapping.camelCaseKey); + } + } +} + +/** + * Builds SAP AI SDK-compatible tool parameters from a JSON schema. + * @param schema - The JSON schema to convert. + * @returns SAP AI SDK tool parameters with type 'object'. + * @internal + */ +function buildSAPToolParameters(schema: Record): SAPToolParameters { + const schemaType = schema.type; + + if (schemaType !== undefined && schemaType !== "object") { + return { + properties: {}, + required: [], + type: "object", + }; + } + + const properties = + schema.properties && typeof schema.properties === "object" + ? (schema.properties as Record) + : {}; + + const required = + Array.isArray(schema.required) && schema.required.every((item) => typeof item === "string") + ? schema.required + : []; + + const additionalFields = Object.fromEntries( + Object.entries(schema).filter( + ([key]) => key !== "type" && key !== "properties" && key !== "required", + ), + ); + + return { + properties, + required, + type: "object", + ...additionalFields, + }; +} + +/** + * Creates a summary of Vercel AI SDK request options for error context. + * @param options - The Vercel AI SDK language model call options. + * @returns A summary object with key request parameters for debugging. + * @internal + */ +function createAISDKRequestBodySummary(options: LanguageModelV3CallOptions): { + hasImageParts: boolean; + maxOutputTokens?: number; + promptMessages: number; + responseFormatType?: string; + seed?: number; + stopSequences?: number; + temperature?: number; + toolChoiceType?: string; + tools: number; + topK?: number; + topP?: number; +} { + return { + hasImageParts: options.prompt.some( + (message) => + message.role === "user" && + message.content.some((part) => part.type === "file" && part.mediaType.startsWith("image/")), + ), + maxOutputTokens: options.maxOutputTokens, + promptMessages: options.prompt.length, + responseFormatType: options.responseFormat?.type, + seed: options.seed, + stopSequences: options.stopSequences?.length, + temperature: options.temperature, + toolChoiceType: options.toolChoice?.type, + tools: options.tools?.length ?? 0, + topK: options.topK, + topP: options.topP, + }; +} + +/** + * Type guard for objects with a callable parse method. + * @param obj - The object to check. + * @returns True if the object has a callable parse method. + * @internal + */ +function hasCallableParse( + obj: Record, +): obj is Record & { parse: (...args: unknown[]) => unknown } { + return typeof obj.parse === "function"; +} + +/** + * Type guard for Zod schema objects. + * @param obj - The value to check. + * @returns True if the value is a Zod schema with _def and parse properties. + * @internal + */ +function isZodSchema(obj: unknown): obj is ZodType { + if (obj === null || typeof obj !== "object") { + return false; + } + const record = obj as Record; + return "_def" in record && "parse" in record && hasCallableParse(record); +} + +/** + * Maps provider finish reasons to Vercel AI SDK LanguageModelV3FinishReason. + * @param reason - The raw finish reason string from the model provider. + * @returns The mapped Vercel AI SDK finish reason object. + * @internal + */ +function mapFinishReason(reason: string | undefined): LanguageModelV3FinishReason { + const raw = reason; + + if (!reason) return { raw, unified: "other" }; + + switch (reason.toLowerCase()) { + case "content_filter": + return { raw, unified: "content-filter" }; + case "end_turn": + case "eos": + case "stop": + case "stop_sequence": + return { raw, unified: "stop" }; + case "error": + return { raw, unified: "error" }; + case "function_call": + case "tool_call": + case "tool_calls": + return { raw, unified: "tool-calls" }; + case "length": + case "max_tokens": + case "max_tokens_reached": + return { raw, unified: "length" }; + default: + return { raw, unified: "other" }; + } +} diff --git a/src/sap-ai-provider-options.test.ts b/src/sap-ai-provider-options.test.ts new file mode 100644 index 0000000..8d583c6 --- /dev/null +++ b/src/sap-ai-provider-options.test.ts @@ -0,0 +1,584 @@ +/** Unit tests for SAP AI Provider Options. */ + +import type { SharedV3Warning } from "@ai-sdk/provider"; + +import { safeValidateTypes } from "@ai-sdk/provider-utils"; +import { describe, expect, it } from "vitest"; + +import { + embeddingModelParamsSchema, + getProviderName, + modelParamsSchema, + SAP_AI_PROVIDER_NAME, + sapAIEmbeddingProviderOptions, + type SAPAIEmbeddingProviderOptions, + sapAILanguageModelProviderOptions, + type SAPAILanguageModelProviderOptions, + validateEmbeddingModelParamsSettings, + validateModelParamsSettings, + validateModelParamsWithWarnings, +} from "./sap-ai-provider-options"; + +describe("sap ai provider name constant", () => { + it("should have the correct provider name", () => { + expect(SAP_AI_PROVIDER_NAME).toBe("sap-ai"); + }); +}); + +describe("getProviderName", () => { + it("should extract provider name from identifier with .chat suffix", () => { + expect(getProviderName("sap-ai.chat")).toBe("sap-ai"); + }); + + it("should extract provider name from identifier with .embedding suffix", () => { + expect(getProviderName("sap-ai.embedding")).toBe("sap-ai"); + }); + + it("should extract provider name from custom provider identifiers", () => { + expect(getProviderName("sap-ai-core.chat")).toBe("sap-ai-core"); + expect(getProviderName("my-custom-provider.embedding")).toBe("my-custom-provider"); + }); + + it("should return the input unchanged if no dot is present", () => { + expect(getProviderName("sap-ai")).toBe("sap-ai"); + expect(getProviderName("openai")).toBe("openai"); + }); + + it("should handle empty string", () => { + expect(getProviderName("")).toBe(""); + }); + + it("should only split on first dot", () => { + expect(getProviderName("sap.ai.chat")).toBe("sap"); + }); +}); + +describe("sapAILanguageModelProviderOptions", () => { + describe("valid options", () => { + it("should accept empty object", async () => { + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({}); + } + }); + + it("should accept includeReasoning boolean", async () => { + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: { includeReasoning: true }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({ includeReasoning: true }); + } + }); + + it("should accept modelParams with temperature", async () => { + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: { modelParams: { temperature: 0.7 } }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({ modelParams: { temperature: 0.7 } }); + } + }); + + it("should accept modelParams with all fields", async () => { + const options = { + includeReasoning: false, + modelParams: { + frequencyPenalty: 0.5, + maxTokens: 1000, + n: 1, + parallel_tool_calls: true, + presencePenalty: 0.3, + temperature: 0.8, + topP: 0.9, + }, + }; + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: options, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual(options); + } + }); + + it("should allow passthrough of unknown modelParams fields", async () => { + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: { + modelParams: { + customField: "custom-value", + temperature: 0.5, + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({ + modelParams: { + customField: "custom-value", + temperature: 0.5, + }, + }); + } + }); + }); + + describe("validation constraints", () => { + it("should reject invalid modelParams", async () => { + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: { modelParams: { temperature: 99 } }, + }); + expect(result.success).toBe(false); + }); + + it("should reject includeReasoning non-boolean", async () => { + const result = await safeValidateTypes({ + schema: sapAILanguageModelProviderOptions, + value: { includeReasoning: "true" }, + }); + expect(result.success).toBe(false); + }); + }); + + describe("type inference", () => { + it("should have correct TypeScript type", () => { + const validOptions: SAPAILanguageModelProviderOptions = { + includeReasoning: true, + modelParams: { + maxTokens: 100, + temperature: 0.5, + }, + }; + expect(validOptions).toBeDefined(); + }); + }); +}); + +describe("sapAIEmbeddingProviderOptions", () => { + describe("valid options", () => { + it("should accept empty object", async () => { + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({}); + } + }); + + it.each([{ type: "text" as const }, { type: "query" as const }, { type: "document" as const }])( + "should accept type '$type'", + async ({ type }) => { + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: { type }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({ type }); + } + }, + ); + + it("should accept modelParams as record", async () => { + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: { modelParams: { dimensions: 1536 } }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual({ modelParams: { dimensions: 1536 } }); + } + }); + + it("should accept all fields together", async () => { + const options = { + modelParams: { customParam: true, dimensions: 1536 }, + type: "query" as const, + }; + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: options, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value).toEqual(options); + } + }); + }); + + describe("validation constraints", () => { + it("should reject invalid type value", async () => { + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: { type: "invalid" }, + }); + expect(result.success).toBe(false); + }); + + it("should reject type as number", async () => { + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: { type: 123 }, + }); + expect(result.success).toBe(false); + }); + + it("should reject invalid modelParams", async () => { + const result = await safeValidateTypes({ + schema: sapAIEmbeddingProviderOptions, + value: { modelParams: { dimensions: -1 } }, + }); + expect(result.success).toBe(false); + }); + }); + + describe("type inference", () => { + it("should have correct TypeScript type", () => { + const validOptions: SAPAIEmbeddingProviderOptions = { + modelParams: { dimensions: 1536 }, + type: "query", + }; + expect(validOptions).toBeDefined(); + }); + }); +}); + +describe("modelParamsSchema", () => { + describe("valid parameters", () => { + it("should accept empty object", () => { + const result = modelParamsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should accept all valid parameters", () => { + const result = modelParamsSchema.safeParse({ + frequencyPenalty: 0.5, + maxTokens: 1000, + n: 2, + parallel_tool_calls: true, + presencePenalty: -0.5, + temperature: 0.7, + topP: 0.9, + }); + expect(result.success).toBe(true); + }); + + it("should accept boundary values", () => { + const result = modelParamsSchema.safeParse({ + frequencyPenalty: -2, + maxTokens: 1, + n: 1, + presencePenalty: 2, + temperature: 0, + topP: 1, + }); + expect(result.success).toBe(true); + }); + + it("should accept unknown additional properties", () => { + const result = modelParamsSchema.safeParse({ + customProperty: "value", + temperature: 0.5, + }); + expect(result.success).toBe(true); + }); + }); + + describe("invalid parameters", () => { + it("should reject temperature below 0", () => { + const result = modelParamsSchema.safeParse({ temperature: -0.1 }); + expect(result.success).toBe(false); + }); + + it("should reject temperature above 2", () => { + const result = modelParamsSchema.safeParse({ temperature: 2.1 }); + expect(result.success).toBe(false); + }); + + it("should reject topP below 0", () => { + const result = modelParamsSchema.safeParse({ topP: -0.1 }); + expect(result.success).toBe(false); + }); + + it("should reject topP above 1", () => { + const result = modelParamsSchema.safeParse({ topP: 1.1 }); + expect(result.success).toBe(false); + }); + + it("should reject frequencyPenalty below -2", () => { + const result = modelParamsSchema.safeParse({ frequencyPenalty: -2.1 }); + expect(result.success).toBe(false); + }); + + it("should reject frequencyPenalty above 2", () => { + const result = modelParamsSchema.safeParse({ frequencyPenalty: 2.1 }); + expect(result.success).toBe(false); + }); + + it("should reject presencePenalty below -2", () => { + const result = modelParamsSchema.safeParse({ presencePenalty: -2.1 }); + expect(result.success).toBe(false); + }); + + it("should reject presencePenalty above 2", () => { + const result = modelParamsSchema.safeParse({ presencePenalty: 2.1 }); + expect(result.success).toBe(false); + }); + + it("should reject non-positive maxTokens", () => { + const result = modelParamsSchema.safeParse({ maxTokens: 0 }); + expect(result.success).toBe(false); + }); + + it("should reject negative maxTokens", () => { + const result = modelParamsSchema.safeParse({ maxTokens: -1 }); + expect(result.success).toBe(false); + }); + + it("should reject non-integer maxTokens", () => { + const result = modelParamsSchema.safeParse({ maxTokens: 100.5 }); + expect(result.success).toBe(false); + }); + + it("should reject non-positive n", () => { + const result = modelParamsSchema.safeParse({ n: 0 }); + expect(result.success).toBe(false); + }); + + it("should reject non-integer n", () => { + const result = modelParamsSchema.safeParse({ n: 1.5 }); + expect(result.success).toBe(false); + }); + + it("should reject non-boolean parallel_tool_calls", () => { + const result = modelParamsSchema.safeParse({ parallel_tool_calls: "true" }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("validateModelParamsSettings", () => { + it("should accept valid modelParams", () => { + expect(() => + validateModelParamsSettings({ + maxTokens: 1000, + temperature: 0.7, + }), + ).not.toThrow(); + }); + + it("should return validated params", () => { + const result = validateModelParamsSettings({ + temperature: 0.5, + topP: 0.9, + }); + expect(result).toEqual({ + temperature: 0.5, + topP: 0.9, + }); + }); + + it("should throw on invalid params", () => { + expect(() => validateModelParamsSettings({ temperature: 99 })).toThrow(); + }); + + it("should throw with descriptive error message", () => { + try { + validateModelParamsSettings({ temperature: -1 }); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeDefined(); + expect(String(error)).toContain("temperature"); + } + }); +}); + +describe("validateModelParamsWithWarnings", () => { + describe("consistency with modelParamsSchema", () => { + const testCases = [ + // Valid values - should NOT produce warnings + { desc: "empty object", expectWarning: false, params: {} }, + { desc: "temperature at min (0)", expectWarning: false, params: { temperature: 0 } }, + { desc: "temperature at max (2)", expectWarning: false, params: { temperature: 2 } }, + { desc: "temperature in range", expectWarning: false, params: { temperature: 0.7 } }, + { desc: "topP at min (0)", expectWarning: false, params: { topP: 0 } }, + { desc: "topP at max (1)", expectWarning: false, params: { topP: 1 } }, + { desc: "topP in range", expectWarning: false, params: { topP: 0.5 } }, + { + desc: "frequencyPenalty at min (-2)", + expectWarning: false, + params: { frequencyPenalty: -2 }, + }, + { + desc: "frequencyPenalty at max (2)", + expectWarning: false, + params: { frequencyPenalty: 2 }, + }, + { + desc: "presencePenalty at min (-2)", + expectWarning: false, + params: { presencePenalty: -2 }, + }, + { desc: "presencePenalty at max (2)", expectWarning: false, params: { presencePenalty: 2 } }, + { desc: "maxTokens at min (1)", expectWarning: false, params: { maxTokens: 1 } }, + { desc: "maxTokens in range", expectWarning: false, params: { maxTokens: 1000 } }, + + // Invalid values - SHOULD produce warnings + { desc: "temperature below min", expectWarning: true, params: { temperature: -0.1 } }, + { desc: "temperature above max", expectWarning: true, params: { temperature: 2.1 } }, + { desc: "topP below min", expectWarning: true, params: { topP: -0.1 } }, + { desc: "topP above max", expectWarning: true, params: { topP: 1.1 } }, + { + desc: "frequencyPenalty below min", + expectWarning: true, + params: { frequencyPenalty: -2.1 }, + }, + { + desc: "frequencyPenalty above max", + expectWarning: true, + params: { frequencyPenalty: 2.1 }, + }, + { desc: "presencePenalty below min", expectWarning: true, params: { presencePenalty: -2.1 } }, + { desc: "presencePenalty above max", expectWarning: true, params: { presencePenalty: 2.1 } }, + { desc: "maxTokens at zero", expectWarning: true, params: { maxTokens: 0 } }, + { desc: "maxTokens negative", expectWarning: true, params: { maxTokens: -1 } }, + ]; + + it.each(testCases)("should $expectWarning for $desc", ({ expectWarning, params }) => { + const warnings: SharedV3Warning[] = []; + validateModelParamsWithWarnings(params, warnings); + + const schemaResult = modelParamsSchema.safeParse(params); + const schemaIsValid = schemaResult.success; + const hasWarnings = warnings.length > 0; + + expect(hasWarnings).toBe(!schemaIsValid); + expect(hasWarnings).toBe(expectWarning); + }); + + it("should produce warnings with type 'other'", () => { + const warnings: SharedV3Warning[] = []; + validateModelParamsWithWarnings({ temperature: 3 }, warnings); + + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]?.type).toBe("other"); + }); + + it("should include parameter name in warning message", () => { + const warnings: SharedV3Warning[] = []; + validateModelParamsWithWarnings({ temperature: 3, topP: 2 }, warnings); + + expect(warnings.length).toBe(2); + const tempWarning = warnings.find( + (w) => w.type === "other" && w.message.includes("temperature"), + ); + const topPWarning = warnings.find((w) => w.type === "other" && w.message.includes("topP")); + expect(tempWarning).toBeDefined(); + expect(topPWarning).toBeDefined(); + }); + }); +}); + +describe("embeddingModelParamsSchema", () => { + describe("valid parameters", () => { + it("should accept empty object", () => { + const result = embeddingModelParamsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should accept all known parameters", () => { + const result = embeddingModelParamsSchema.safeParse({ + dimensions: 1536, + encoding_format: "float", + normalize: true, + }); + expect(result.success).toBe(true); + }); + + it("should accept dimensions as positive integer", () => { + const result = embeddingModelParamsSchema.safeParse({ dimensions: 256 }); + expect(result.success).toBe(true); + }); + + it("should accept all encoding_format values", () => { + for (const format of ["float", "base64", "binary"] as const) { + const result = embeddingModelParamsSchema.safeParse({ encoding_format: format }); + expect(result.success).toBe(true); + } + }); + + it("should accept unknown additional properties", () => { + const result = embeddingModelParamsSchema.safeParse({ + customProperty: "value", + dimensions: 1536, + }); + expect(result.success).toBe(true); + }); + }); + + describe("invalid parameters", () => { + it("should reject non-positive dimensions", () => { + const result = embeddingModelParamsSchema.safeParse({ dimensions: 0 }); + expect(result.success).toBe(false); + }); + + it("should reject negative dimensions", () => { + const result = embeddingModelParamsSchema.safeParse({ dimensions: -1 }); + expect(result.success).toBe(false); + }); + + it("should reject non-integer dimensions", () => { + const result = embeddingModelParamsSchema.safeParse({ dimensions: 1.5 }); + expect(result.success).toBe(false); + }); + + it("should reject invalid encoding_format", () => { + const result = embeddingModelParamsSchema.safeParse({ encoding_format: "invalid" }); + expect(result.success).toBe(false); + }); + + it("should reject non-boolean normalize", () => { + const result = embeddingModelParamsSchema.safeParse({ normalize: "true" }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("validateEmbeddingModelParamsSettings", () => { + it("should accept valid embedding params", () => { + expect(() => + validateEmbeddingModelParamsSettings({ + dimensions: 1536, + encoding_format: "float", + }), + ).not.toThrow(); + }); + + it("should throw on invalid dimensions", () => { + expect(() => validateEmbeddingModelParamsSettings({ dimensions: -1 })).toThrow(); + }); + + it("should return validated params", () => { + const result = validateEmbeddingModelParamsSettings({ + dimensions: 1536, + normalize: true, + }); + expect(result).toEqual({ + dimensions: 1536, + normalize: true, + }); + }); +}); diff --git a/src/sap-ai-provider-options.ts b/src/sap-ai-provider-options.ts new file mode 100644 index 0000000..549b6bf --- /dev/null +++ b/src/sap-ai-provider-options.ts @@ -0,0 +1,155 @@ +/** + * Zod schemas for runtime validation of per-call options via `providerOptions['sap-ai']`. + * + * Implements dual validation: strict throws for providerOptions, warnings for Vercel AI SDK params. + * @module + */ + +import type { SharedV3Warning } from "@ai-sdk/provider"; +import type { InferSchema } from "@ai-sdk/provider-utils"; + +import { lazySchema, zodSchema } from "@ai-sdk/provider-utils"; +import { z } from "zod"; + +/** Default provider name used as key in `providerOptions` and `providerMetadata` objects. */ +export const SAP_AI_PROVIDER_NAME = "sap-ai" as const; + +/** + * Extracts the provider name from a provider identifier (e.g., "sap-ai.chat" → "sap-ai"). + * @param providerIdentifier - The full provider identifier string. + * @returns The provider name without any suffix. + */ +export function getProviderName(providerIdentifier: string): string { + const dotIndex = providerIdentifier.indexOf("."); + return dotIndex === -1 ? providerIdentifier : providerIdentifier.slice(0, dotIndex); +} + +/** + * Zod schema for model generation parameters. + * @internal + */ +export const modelParamsSchema = z + .object({ + /** Frequency penalty value (-2.0 to 2.0). */ + frequencyPenalty: z.number().min(-2).max(2).optional(), + /** Maximum number of tokens to generate (must be a positive integer). */ + maxTokens: z.number().int().positive().optional(), + /** Number of completions to generate (not supported by Amazon/Anthropic models). */ + n: z.number().int().positive().optional(), + /** Whether to enable parallel tool calls. */ + parallel_tool_calls: z.boolean().optional(), + /** Presence penalty value (-2.0 to 2.0). */ + presencePenalty: z.number().min(-2).max(2).optional(), + /** Sampling temperature value (0 to 2). */ + temperature: z.number().min(0).max(2).optional(), + /** Nucleus sampling probability (0 to 1). */ + topP: z.number().min(0).max(1).optional(), + }) + .catchall(z.unknown()); + +/** Model generation parameters type inferred from Zod schema. */ +export type ModelParams = z.infer; + +/** + * Zod schema for embedding model parameters. + * @internal + */ +export const embeddingModelParamsSchema = z + .object({ + /** Output embedding dimensions (model-dependent, must be a positive integer). */ + dimensions: z.number().int().positive().optional(), + /** Encoding format for embeddings: 'float' (default), 'base64', or 'binary'. */ + encoding_format: z.enum(["base64", "binary", "float"]).optional(), + /** Whether to normalize embedding vectors to unit length. */ + normalize: z.boolean().optional(), + }) + .catchall(z.unknown()); + +/** Embedding model parameters type inferred from Zod schema. */ +export type EmbeddingModelParams = z.infer; + +/** + * Validates embedding model parameters from constructor settings. + * @param modelParams - The model parameters to validate. + * @returns The validated embedding model parameters. + * @throws {z.ZodError} If validation fails. + */ +export function validateEmbeddingModelParamsSettings(modelParams: unknown): EmbeddingModelParams { + return embeddingModelParamsSchema.parse(modelParams); +} + +/** + * Validates model parameters from constructor settings. + * @param modelParams - The model parameters to validate. + * @returns The validated model parameters. + * @throws {z.ZodError} If validation fails. + */ +export function validateModelParamsSettings(modelParams: unknown): ModelParams { + return modelParamsSchema.parse(modelParams); +} + +/** + * Validates Vercel AI SDK standard parameters and adds warnings for out-of-range values. + * @param params - The parameters to validate. + * @param params.frequencyPenalty - Frequency penalty value (-2.0 to 2.0). + * @param params.maxTokens - Maximum tokens value (positive integer). + * @param params.presencePenalty - Presence penalty value (-2.0 to 2.0). + * @param params.temperature - Temperature value (0 to 2). + * @param params.topP - Top-p value (0 to 1). + * @param warnings - Array to collect validation warnings. + */ +export function validateModelParamsWithWarnings( + params: { + frequencyPenalty?: number; + maxTokens?: number; + presencePenalty?: number; + temperature?: number; + topP?: number; + }, + warnings: SharedV3Warning[], +): void { + const result = modelParamsSchema.safeParse(params); + + if (!result.success) { + for (const issue of result.error.issues) { + const path = issue.path.join("."); + const value = path ? (params as Record)[path] : undefined; + warnings.push({ + message: `${path}=${String(value)} is invalid: ${issue.message}. The API may reject this value.`, + type: "other", + }); + } + } +} + +/** Zod schema for SAP AI language model provider options passed via `providerOptions['sap-ai']` object. */ +export const sapAILanguageModelProviderOptions = lazySchema(() => + zodSchema( + z.object({ + /** Whether to include assistant reasoning parts in the response. */ + includeReasoning: z.boolean().optional(), + /** Model generation parameters for this specific call. */ + modelParams: modelParamsSchema.optional(), + }), + ), +); + +/** SAP AI language model provider options type inferred from Zod schema. */ +export type SAPAILanguageModelProviderOptions = InferSchema< + typeof sapAILanguageModelProviderOptions +>; + +/** Zod schema for SAP AI embedding model provider options passed via `providerOptions['sap-ai']` object. */ +export const sapAIEmbeddingProviderOptions = lazySchema(() => + zodSchema( + z.object({ + /** Additional model parameters for this call. */ + modelParams: embeddingModelParamsSchema.optional(), + /** Embedding task type: 'text' (default), 'query', or 'document'. */ + type: z.enum(["document", "query", "text"]).optional(), + }), + ), +); + +/** SAP AI embedding model provider options type inferred from Zod schema. */ +export type SAPAIEmbeddingProviderOptions = InferSchema; diff --git a/src/sap-ai-provider.test.ts b/src/sap-ai-provider.test.ts index f6edbd0..e95a53a 100644 --- a/src/sap-ai-provider.test.ts +++ b/src/sap-ai-provider.test.ts @@ -1,61 +1,181 @@ -import { describe, it, expect } from "vitest"; -import { createSAPAIProvider } from "./sap-ai-provider"; +/** Unit tests for SAP AI Provider V3. */ + +import { NoSuchModelError } from "@ai-sdk/provider"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock setGlobalLogLevel from @sap-cloud-sdk/util +vi.mock("@sap-cloud-sdk/util", async () => { + const actual = await vi.importActual("@sap-cloud-sdk/util"); + return { + ...actual, + setGlobalLogLevel: vi.fn(), + }; +}); + +import { setGlobalLogLevel } from "@sap-cloud-sdk/util"; + +import { createSAPAIProvider, sapai } from "./sap-ai-provider"; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("createSAPAIProvider", () => { - it("should create a provider synchronously", () => { + it("should create a functional provider instance", () => { const provider = createSAPAIProvider(); expect(provider).toBeDefined(); expect(typeof provider).toBe("function"); - }); - it("should have a chat method", () => { - const provider = createSAPAIProvider(); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(provider.chat).toBeDefined(); - expect(typeof provider.chat).toBe("function"); + const model = provider("gpt-4o"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("gpt-4o"); + expect(model.provider).toBe("sap-ai.chat"); }); - it("should create a model when called", () => { + it("should create models via chat method", () => { const provider = createSAPAIProvider(); - const model = provider("gpt-4o"); + const model = provider.chat("gpt-4o"); expect(model).toBeDefined(); expect(model.modelId).toBe("gpt-4o"); - expect(model.provider).toBe("sap-ai"); + expect(model.provider).toBe("sap-ai.chat"); + + const modelWithSettings = provider.chat("gpt-4o", { + modelParams: { temperature: 0.8 }, + }); + expect(modelWithSettings).toBeDefined(); }); - it("should accept resource group configuration", () => { - const provider = createSAPAIProvider({ + it("should accept configuration options", () => { + const providerWithResourceGroup = createSAPAIProvider({ resourceGroup: "production", }); - const model = provider("gpt-4o"); - expect(model).toBeDefined(); - }); + expect(providerWithResourceGroup("gpt-4o")).toBeDefined(); - it("should accept default settings", () => { - const provider = createSAPAIProvider({ + const providerWithDefaults = createSAPAIProvider({ defaultSettings: { modelParams: { temperature: 0.5, }, }, }); - const model = provider("gpt-4o"); - expect(model).toBeDefined(); + expect(providerWithDefaults("gpt-4o")).toBeDefined(); + }); + + describe("defaultSettings.modelParams validation", () => { + it("should throw on invalid modelParams", () => { + expect(() => + createSAPAIProvider({ + defaultSettings: { modelParams: { temperature: 5 } }, + }), + ).toThrow(); + }); + + it("should accept valid modelParams", () => { + expect(() => + createSAPAIProvider({ + defaultSettings: { modelParams: { temperature: 0.7 } }, + }), + ).not.toThrow(); + }); + }); + + it("should accept deploymentId and destination configurations", () => { + const providerWithDeploymentId = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", + }); + expect(providerWithDeploymentId("gpt-4o")).toBeDefined(); + + const providerWithDestination = createSAPAIProvider({ + destination: { + url: "https://custom-ai-core.example.com", + }, + }); + expect(providerWithDestination("gpt-4o")).toBeDefined(); + + const providerWithBoth = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", + destination: { + url: "https://custom-ai-core.example.com", + }, + }); + expect(providerWithBoth("gpt-4o")).toBeDefined(); + }); + + it("should accept both deploymentId and resourceGroup", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const provider = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", + resourceGroup: "production", + }); + + expect(provider("gpt-4o")).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith( + "createSAPAIProvider: both 'deploymentId' and 'resourceGroup' were provided; using 'deploymentId' and ignoring 'resourceGroup'.", + ); + }); + + it("should allow disabling ambiguous config warnings", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const provider = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", + resourceGroup: "production", + warnOnAmbiguousConfig: false, + }); + + expect(provider("gpt-4o")).toBeDefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + describe("log level configuration", () => { + beforeEach(() => { + vi.mocked(setGlobalLogLevel).mockClear(); + }); + + afterEach(() => { + delete process.env.SAP_CLOUD_SDK_LOG_LEVEL; + }); + + it("should set SAP Cloud SDK log level to warn by default", () => { + createSAPAIProvider(); + + expect(setGlobalLogLevel).toHaveBeenCalledWith("warn"); + }); + + it("should allow custom log level configuration", () => { + createSAPAIProvider({ logLevel: "debug" }); + + expect(setGlobalLogLevel).toHaveBeenCalledWith("debug"); + }); + + it("should respect SAP_CLOUD_SDK_LOG_LEVEL environment variable", () => { + process.env.SAP_CLOUD_SDK_LOG_LEVEL = "info"; + + createSAPAIProvider({ logLevel: "debug" }); + + expect(setGlobalLogLevel).not.toHaveBeenCalled(); + }); }); - it("should merge per-call settings with defaults", () => { + it("should deep merge modelParams from defaults and call-time settings", () => { const provider = createSAPAIProvider({ defaultSettings: { modelParams: { + frequencyPenalty: 0.2, + presencePenalty: 0.1, temperature: 0.5, }, }, }); + const model = provider("gpt-4o", { modelParams: { - maxTokens: 1000, + frequencyPenalty: 0.5, + maxTokens: 2000, }, }); + expect(model).toBeDefined(); }); @@ -66,4 +186,254 @@ describe("createSAPAIProvider", () => { new provider("gpt-4o"); }).toThrow("cannot be called with the new keyword"); }); + + describe("embedding models", () => { + it("should create embedding models via embedding method", () => { + const provider = createSAPAIProvider(); + const model = provider.embedding("text-embedding-ada-002"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-ada-002"); + expect(model.provider).toBe("sap-ai.embedding"); + }); + + it("should create embedding models with settings", () => { + const provider = createSAPAIProvider(); + const model = provider.embedding("text-embedding-3-small", { + type: "document", + }); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-3-small"); + }); + + it("should support deprecated textEmbeddingModel method", () => { + const provider = createSAPAIProvider(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const model = provider.textEmbeddingModel("text-embedding-ada-002"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-ada-002"); + }); + }); + + describe("provider v3 compliance", () => { + it("should have specificationVersion 'v3'", () => { + const provider = createSAPAIProvider(); + expect(provider.specificationVersion).toBe("v3"); + }); + + it("should create language models via languageModel method", () => { + const provider = createSAPAIProvider(); + const model = provider.languageModel("gpt-4o"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("gpt-4o"); + expect(model.provider).toBe("sap-ai.chat"); + }); + + it("should create embedding models via embeddingModel method", () => { + const provider = createSAPAIProvider(); + const model = provider.embeddingModel("text-embedding-ada-002"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-ada-002"); + expect(model.provider).toBe("sap-ai.embedding"); + }); + + it("should throw NoSuchModelError with detailed information", () => { + const provider = createSAPAIProvider(); + + const testCases = ["dall-e-3", "stable-diffusion", "midjourney"]; + + for (const modelId of testCases) { + try { + provider.imageModel(modelId); + expect.fail("Should have thrown NoSuchModelError"); + } catch (error) { + expect(error).toBeInstanceOf(NoSuchModelError); + const noSuchModelError = error as NoSuchModelError; + expect(noSuchModelError.modelId).toBe(modelId); + expect(noSuchModelError.modelType).toBe("imageModel"); + expect(noSuchModelError.message).toContain( + "SAP AI Core Orchestration Service does not support image generation", + ); + } + } + }); + }); +}); + +describe("provider name", () => { + describe("language models use {name}.chat provider identifier", () => { + it("should use default provider identifier", () => { + const provider = createSAPAIProvider(); + const model = provider("gpt-4o"); + expect(model.provider).toBe("sap-ai.chat"); + }); + + it("should use custom provider name", () => { + const provider = createSAPAIProvider({ name: "sap-ai-core" }); + + expect(provider("gpt-4o").provider).toBe("sap-ai-core.chat"); + expect(provider.chat("gpt-4o").provider).toBe("sap-ai-core.chat"); + expect(provider.languageModel("gpt-4o").provider).toBe("sap-ai-core.chat"); + }); + }); + + describe("embedding models use {name}.embedding provider identifier", () => { + it("should use custom provider name for embeddings", () => { + const provider = createSAPAIProvider({ name: "sap-ai-embeddings" }); + + expect(provider.embedding("text-embedding-ada-002").provider).toBe( + "sap-ai-embeddings.embedding", + ); + expect(provider.embeddingModel("text-embedding-3-small").provider).toBe( + "sap-ai-embeddings.embedding", + ); + }); + }); + + describe("provider name works with other settings", () => { + it("should work with defaultSettings and resourceGroup", () => { + const provider = createSAPAIProvider({ + defaultSettings: { + modelParams: { temperature: 0.7 }, + }, + name: "sap-ai-prod", + resourceGroup: "production", + }); + const model = provider("gpt-4o"); + expect(model.provider).toBe("sap-ai-prod.chat"); + }); + + it("should work with deploymentId", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const provider = createSAPAIProvider({ + deploymentId: "d65d81e7c077e583", + name: "sap-ai-deployment", + resourceGroup: "default", + }); + const model = provider("gpt-4o"); + expect(model.provider).toBe("sap-ai-deployment.chat"); + + warnSpy.mockRestore(); + }); + }); +}); + +describe("sapai default provider", () => { + it("should expose provider entrypoint", () => { + expect(sapai).toBeDefined(); + expect(typeof sapai).toBe("function"); + }); + + it("should expose chat method", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(sapai.chat).toBeDefined(); + expect(typeof sapai.chat).toBe("function"); + }); + + it("should create language models via direct call", () => { + const model = sapai("gpt-4o"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("gpt-4o"); + expect(model.provider).toBe("sap-ai.chat"); + }); + + it("should create a model via chat method", () => { + const model = sapai.chat("gpt-4o"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("gpt-4o"); + expect(model.provider).toBe("sap-ai.chat"); + expect(model.specificationVersion).toBe("v3"); + }); + + it("should create a model with settings", () => { + const model = sapai("gpt-4o", { modelParams: { temperature: 0.5 } }); + expect(model).toBeDefined(); + expect(model.modelId).toBe("gpt-4o"); + }); + + describe("embedding models", () => { + it("should expose embedding entrypoint", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(sapai.embedding).toBeDefined(); + expect(typeof sapai.embedding).toBe("function"); + }); + + it("should expose textEmbeddingModel entrypoint", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-deprecated + expect(sapai.textEmbeddingModel).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(typeof sapai.textEmbeddingModel).toBe("function"); + }); + + it("should create an embedding model via embedding method", () => { + const model = sapai.embedding("text-embedding-ada-002"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-ada-002"); + expect(model.provider).toBe("sap-ai.embedding"); + expect(model.specificationVersion).toBe("v3"); + }); + + it("should create an embedding model via textEmbeddingModel method (deprecated)", () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + const model = sapai.textEmbeddingModel("text-embedding-3-small"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-3-small"); + expect(model.provider).toBe("sap-ai.embedding"); + expect(model.specificationVersion).toBe("v3"); + }); + + it("should have correct embedding model properties", () => { + const model = sapai.embedding("text-embedding-3-small"); + expect(model.maxEmbeddingsPerCall).toBe(2048); + expect(model.supportsParallelCalls).toBe(true); + }); + }); + + describe("provider v3 compliance", () => { + it("should have specificationVersion 'v3'", () => { + expect(sapai.specificationVersion).toBe("v3"); + }); + + it("should expose languageModel entrypoint", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(sapai.languageModel).toBeDefined(); + expect(typeof sapai.languageModel).toBe("function"); + }); + + it("should create a model via languageModel method", () => { + const model = sapai.languageModel("gpt-4o"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("gpt-4o"); + expect(model.provider).toBe("sap-ai.chat"); + expect(model.specificationVersion).toBe("v3"); + }); + + it("should expose embeddingModel entrypoint", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(sapai.embeddingModel).toBeDefined(); + expect(typeof sapai.embeddingModel).toBe("function"); + }); + + it("should create an embedding model via embeddingModel method", () => { + const model = sapai.embeddingModel("text-embedding-ada-002"); + expect(model).toBeDefined(); + expect(model.modelId).toBe("text-embedding-ada-002"); + expect(model.provider).toBe("sap-ai.embedding"); + expect(model.specificationVersion).toBe("v3"); + }); + + it("should expose imageModel entrypoint", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(sapai.imageModel).toBeDefined(); + expect(typeof sapai.imageModel).toBe("function"); + }); + + it("should throw NoSuchModelError when calling imageModel", () => { + expect(() => sapai.imageModel("dall-e-3")).toThrow(NoSuchModelError); + }); + + it("should include modelId in error message", () => { + expect(() => sapai.imageModel("dall-e-3")).toThrow("Model 'dall-e-3' is not available"); + }); + }); }); diff --git a/src/sap-ai-provider.ts b/src/sap-ai-provider.ts index df1c04d..371d381 100644 --- a/src/sap-ai-provider.ts +++ b/src/sap-ai-provider.ts @@ -1,268 +1,191 @@ -import { ProviderV2 } from "@ai-sdk/provider"; +import type { DeploymentIdConfig, ResourceGroupConfig } from "@sap-ai-sdk/ai-api/internal.js"; import type { HttpDestinationOrFetchOptions } from "@sap-cloud-sdk/connectivity"; -import type { - ResourceGroupConfig, - DeploymentIdConfig, -} from "@sap-ai-sdk/ai-api/internal.js"; -import { SAPAIChatLanguageModel } from "./sap-ai-chat-language-model"; -import { SAPAIModelId, SAPAISettings } from "./sap-ai-chat-settings"; + +import { NoSuchModelError, ProviderV3 } from "@ai-sdk/provider"; +import { setGlobalLogLevel } from "@sap-cloud-sdk/util"; + +import { deepMerge } from "./deep-merge.js"; +import { + SAPAIEmbeddingModel, + SAPAIEmbeddingModelId, + SAPAIEmbeddingSettings, +} from "./sap-ai-embedding-model.js"; +import { SAPAILanguageModel } from "./sap-ai-language-model.js"; +import { SAP_AI_PROVIDER_NAME, validateModelParamsSettings } from "./sap-ai-provider-options.js"; +import { SAPAIModelId, SAPAISettings } from "./sap-ai-settings.js"; + +/** Deployment configuration type used by the SAP AI SDK. */ +export type DeploymentConfig = DeploymentIdConfig | ResourceGroupConfig; /** - * SAP AI Provider interface. - * - * This is the main interface for creating and configuring SAP AI Core models. - * It extends the standard Vercel AI SDK ProviderV2 interface with SAP-specific functionality. - * - * @example - * ```typescript - * const provider = createSAPAIProvider({ - * resourceGroup: 'default' - * }); - * - * // Create a model instance - * const model = provider('gpt-4o', { - * modelParams: { - * temperature: 0.7, - * maxTokens: 1000 - * } - * }); - * - * // Or use the explicit chat method - * const chatModel = provider.chat('gpt-4o'); - * ``` + * SAP AI Provider interface for creating and configuring SAP AI Core models. + * Extends the Vercel AI SDK ProviderV3 interface with SAP-specific functionality. */ -export interface SAPAIProvider extends ProviderV2 { +export interface SAPAIProvider extends ProviderV3 { + /** Creates a language model instance. */ + (modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel; + + /** Creates a language model instance (custom convenience method). */ + chat(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel; + + /** Creates an embedding model instance (custom convenience method). */ + embedding(modelId: SAPAIEmbeddingModelId, settings?: SAPAIEmbeddingSettings): SAPAIEmbeddingModel; + + /** Creates an embedding model instance (Vercel AI SDK ProviderV3 standard method). */ + embeddingModel( + modelId: SAPAIEmbeddingModelId, + settings?: SAPAIEmbeddingSettings, + ): SAPAIEmbeddingModel; + /** - * Create a language model instance. - * - * @param modelId - The SAP AI Core model identifier (e.g., 'gpt-4o', 'anthropic--claude-3.5-sonnet') - * @param settings - Optional model configuration settings - * @returns Configured SAP AI chat language model instance + * Image model stub - always throws NoSuchModelError. + * SAP AI Core Orchestration Service does not support image generation. */ - (modelId: SAPAIModelId, settings?: SAPAISettings): SAPAIChatLanguageModel; + imageModel(modelId: string): never; + + /** Creates a language model instance (Vercel AI SDK ProviderV3 standard method). */ + languageModel(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAILanguageModel; /** - * Explicit method for creating chat models. - * - * This method is equivalent to calling the provider function directly, - * but provides a more explicit API for chat-based interactions. - * - * @param modelId - The SAP AI Core model identifier - * @param settings - Optional model configuration settings - * @returns Configured SAP AI chat language model instance + * Creates a text embedding model instance. + * @deprecated Use `embeddingModel()` instead. */ - chat(modelId: SAPAIModelId, settings?: SAPAISettings): SAPAIChatLanguageModel; + textEmbeddingModel( + modelId: SAPAIEmbeddingModelId, + settings?: SAPAIEmbeddingSettings, + ): SAPAIEmbeddingModel; } /** * Configuration settings for the SAP AI Provider. - * - * This interface defines all available options for configuring the SAP AI Core connection - * using the official SAP AI SDK. The SDK handles authentication automatically when - * running on SAP BTP (via service binding) or locally (via AICORE_SERVICE_KEY env var). - * - * @example - * ```typescript - * // Using default configuration (auto-detects service binding or env var) - * const provider = createSAPAIProvider(); - * - * // With specific resource group - * const provider = createSAPAIProvider({ - * resourceGroup: 'production' - * }); - * - * // With custom destination - * const provider = createSAPAIProvider({ - * destination: { - * url: 'https://my-ai-core-instance.cfapps.eu10.hana.ondemand.com' - * } - * }); - * ``` + * See {@link createSAPAIProvider} for authentication details. */ export interface SAPAIProviderSettings { - /** - * SAP AI Core resource group. - * - * Logical grouping of AI resources in SAP AI Core. - * Used for resource isolation and access control. - * Different resource groups can have different permissions and quotas. - * - * @default 'default' - * @example - * ```typescript - * resourceGroup: 'default' // Default resource group - * resourceGroup: 'production' // Production environment - * resourceGroup: 'development' // Development environment - * ``` - */ - resourceGroup?: string; + /** Default model settings applied to every model instance. Per-call settings override these. */ + readonly defaultSettings?: SAPAISettings; + + /** SAP AI Core deployment ID. If not provided, the SDK resolves deployment automatically. */ + readonly deploymentId?: string; + + /** Custom destination configuration for SAP AI Core. */ + readonly destination?: HttpDestinationOrFetchOptions; /** - * SAP AI Core deployment ID. - * - * A specific deployment ID to use for orchestration requests. - * If not provided, the SDK will resolve the deployment automatically. - * - * @example - * ```typescript - * deploymentId: 'd65d81e7c077e583' - * ``` + * Log level for SAP Cloud SDK loggers. + * Controls verbosity of internal SAP SDK logging (e.g., authentication, service binding). + * Note: SAP_CLOUD_SDK_LOG_LEVEL environment variable takes precedence if set. + * @default 'warn' */ - deploymentId?: string; + readonly logLevel?: "debug" | "error" | "info" | "warn"; /** - * Custom destination configuration for SAP AI Core. - * - * Override the default destination detection. Useful for: - * - Custom proxy configurations - * - Non-standard SAP AI Core setups - * - Testing environments - * - * @example - * ```typescript - * destination: { - * url: 'https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com' - * } - * ``` + * Provider name used as key for `providerOptions` and `providerMetadata`. + * @default 'sap-ai' */ - destination?: HttpDestinationOrFetchOptions; + readonly name?: string; /** - * Default model settings applied to every model instance created by this provider. - * Per-call settings provided to the model will override these. + * SAP AI Core resource group for resource isolation and access control. + * @default 'default' */ - defaultSettings?: SAPAISettings; -} + readonly resourceGroup?: string; -/** - * Deployment configuration type used by SAP AI SDK. - */ -export type DeploymentConfig = ResourceGroupConfig | DeploymentIdConfig; + /** Whether to emit warnings for ambiguous configurations (e.g. both deploymentId and resourceGroup). */ + readonly warnOnAmbiguousConfig?: boolean; +} /** - * Creates a SAP AI Core provider instance for use with Vercel AI SDK. + * Creates a SAP AI Core provider instance for use with the Vercel AI SDK. * - * This is the main entry point for integrating SAP AI Core with the Vercel AI SDK. - * It uses the official SAP AI SDK (@sap-ai-sdk/orchestration) under the hood, - * which handles authentication and API communication automatically. - * - * **Authentication:** - * The SAP AI SDK automatically handles authentication: - * 1. On SAP BTP: Uses service binding (VCAP_SERVICES) - * 2. Locally: Uses AICORE_SERVICE_KEY environment variable - * - * **Key Features:** - * - Automatic authentication via SAP AI SDK - * - Support for all SAP AI Core orchestration models - * - Streaming and non-streaming responses - * - Tool calling support - * - Data masking (DPI) - * - Content filtering - * - * @param options - Configuration options for the provider - * @returns A configured SAP AI provider - * - * @example - * **Basic Usage** - * ```typescript - * import { createSAPAIProvider } from '@mymediset/sap-ai-provider'; - * import { generateText } from 'ai'; - * - * const provider = createSAPAIProvider(); - * - * const result = await generateText({ - * model: provider('gpt-4o'), - * prompt: 'Hello, world!' - * }); - * ``` - * - * @example - * **With Resource Group** - * ```typescript - * const provider = createSAPAIProvider({ - * resourceGroup: 'production' - * }); - * - * const model = provider('anthropic--claude-3.5-sonnet', { - * modelParams: { - * temperature: 0.3, - * maxTokens: 2000 - * } - * }); - * ``` - * - * @example - * **With Default Settings** - * ```typescript - * const provider = createSAPAIProvider({ - * defaultSettings: { - * modelParams: { - * temperature: 0.7 - * } - * } - * }); - * ``` + * Uses the official SAP AI SDK (@sap-ai-sdk/orchestration) for authentication + * and API communication. Authentication is automatic via service binding + * (VCAP_SERVICES on SAP BTP) or AICORE_SERVICE_KEY environment variable. + * @param options - Provider configuration options. + * @returns A configured SAP AI provider instance. */ -export function createSAPAIProvider( - options: SAPAIProviderSettings = {}, -): SAPAIProvider { +export function createSAPAIProvider(options: SAPAIProviderSettings = {}): SAPAIProvider { + if (options.defaultSettings?.modelParams) { + validateModelParamsSettings(options.defaultSettings.modelParams); + } + + const providerName = options.name ?? SAP_AI_PROVIDER_NAME; + const resourceGroup = options.resourceGroup ?? "default"; - // Build deployment config for SAP AI SDK + const warnOnAmbiguousConfig = options.warnOnAmbiguousConfig ?? true; + + if (warnOnAmbiguousConfig && options.deploymentId && options.resourceGroup) { + console.warn( + "createSAPAIProvider: both 'deploymentId' and 'resourceGroup' were provided; using 'deploymentId' and ignoring 'resourceGroup'.", + ); + } + + if (!process.env.SAP_CLOUD_SDK_LOG_LEVEL) { + const logLevel = options.logLevel ?? "warn"; + setGlobalLogLevel(logLevel); + } + const deploymentConfig: DeploymentConfig = options.deploymentId ? { deploymentId: options.deploymentId } : { resourceGroup }; - // Create the model factory function const createModel = (modelId: SAPAIModelId, settings: SAPAISettings = {}) => { const mergedSettings: SAPAISettings = { ...options.defaultSettings, ...settings, - modelParams: { - ...(options.defaultSettings?.modelParams ?? {}), - ...(settings.modelParams ?? {}), - }, + filtering: settings.filtering ?? options.defaultSettings?.filtering, + masking: settings.masking ?? options.defaultSettings?.masking, + modelParams: deepMerge( + options.defaultSettings?.modelParams ?? {}, + settings.modelParams ?? {}, + ), + tools: settings.tools ?? options.defaultSettings?.tools, }; - return new SAPAIChatLanguageModel(modelId, mergedSettings, { - provider: "sap-ai", + return new SAPAILanguageModel(modelId, mergedSettings, { deploymentConfig, destination: options.destination, + provider: `${providerName}.chat`, + }); + }; + + const createEmbeddingModel = ( + modelId: SAPAIEmbeddingModelId, + settings: SAPAIEmbeddingSettings = {}, + ): SAPAIEmbeddingModel => { + return new SAPAIEmbeddingModel(modelId, settings, { + deploymentConfig, + destination: options.destination, + provider: `${providerName}.embedding`, }); }; - // Create the provider function const provider = function (modelId: SAPAIModelId, settings?: SAPAISettings) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (new.target) { - throw new Error( - "The SAP AI provider function cannot be called with the new keyword.", - ); + throw new Error("The SAP AI provider function cannot be called with the new keyword."); } return createModel(modelId, settings); }; + provider.specificationVersion = "v3"; provider.chat = createModel; + provider.languageModel = createModel; + provider.embedding = createEmbeddingModel; + provider.textEmbeddingModel = createEmbeddingModel; + provider.embeddingModel = createEmbeddingModel; + + provider.imageModel = (modelId: string) => { + throw new NoSuchModelError({ + message: `SAP AI Core Orchestration Service does not support image generation. Model '${modelId}' is not available.`, + modelId, + modelType: "imageModel", + }); + }; return provider as SAPAIProvider; } -/** - * Default SAP AI provider instance. - * - * Uses the default configuration which auto-detects authentication - * from service binding (SAP BTP) or AICORE_SERVICE_KEY environment variable. - * - * @example - * ```typescript - * import { sapai } from '@mymediset/sap-ai-provider'; - * import { generateText } from 'ai'; - * - * const result = await generateText({ - * model: sapai('gpt-4o'), - * prompt: 'Hello!' - * }); - * ``` - */ +/** Default SAP AI provider instance with automatic authentication via SAP AI SDK. */ export const sapai = createSAPAIProvider(); diff --git a/src/sap-ai-settings.ts b/src/sap-ai-settings.ts new file mode 100644 index 0000000..e3a6f4d --- /dev/null +++ b/src/sap-ai-settings.ts @@ -0,0 +1,122 @@ +import type { + ChatCompletionTool, + ChatModel, + FilteringModule, + GroundingModule, + MaskingModule, + TranslationModule, +} from "@sap-ai-sdk/orchestration"; + +/** + * Supported model IDs in SAP AI Core. + * Actual availability depends on your SAP AI Core tenant configuration. + */ +export type SAPAIModelId = ChatModel; + +/** + * Settings for configuring SAP AI Core model behavior. + * Controls model parameters, data masking, content filtering, and tool usage. + */ +export interface SAPAISettings { + /** Filtering configuration for input and output content safety. */ + readonly filtering?: FilteringModule; + + /** Grounding module configuration for document-based retrieval (RAG). */ + readonly grounding?: GroundingModule; + + /** + * Whether to include assistant reasoning parts in the response. + * @default false + */ + readonly includeReasoning?: boolean; + + /** Masking configuration for data anonymization/pseudonymization via SAP DPI. */ + readonly masking?: MaskingModule; + + /** Model generation parameters that control the output. */ + readonly modelParams?: { + /** Frequency penalty between -2.0 and 2.0. */ + readonly frequencyPenalty?: number; + /** Maximum number of tokens to generate. */ + readonly maxTokens?: number; + /** Number of completions to generate (not supported by Amazon/Anthropic). */ + readonly n?: number; + /** Whether to enable parallel tool calls. */ + readonly parallel_tool_calls?: boolean; + /** Presence penalty between -2.0 and 2.0. */ + readonly presencePenalty?: number; + /** Sampling temperature between 0 and 2. */ + readonly temperature?: number; + /** Nucleus sampling parameter between 0 and 1. */ + readonly topP?: number; + }; + + /** Specific version of the model to use (defaults to latest). */ + readonly modelVersion?: string; + + /** Response format for structured output (OpenAI-compatible). */ + readonly responseFormat?: + | { + readonly json_schema: { + readonly description?: string; + readonly name: string; + readonly schema?: unknown; + readonly strict?: boolean | null; + }; + readonly type: "json_schema"; + } + | { readonly type: "json_object" } + | { readonly type: "text" }; + + /** Tool definitions in SAP AI SDK format. */ + readonly tools?: ChatCompletionTool[]; + + /** Translation module configuration for input/output translation. */ + readonly translation?: TranslationModule; +} + +/** SAP AI SDK types re-exported for convenience and direct usage. */ +export type { + FilteringModule, + GroundingModule, + MaskingModule, + TranslationModule, +} from "@sap-ai-sdk/orchestration"; + +export { + buildAzureContentSafetyFilter, + buildDocumentGroundingConfig, + buildDpiMaskingProvider, + buildLlamaGuard38BFilter, + buildTranslationConfig, +} from "@sap-ai-sdk/orchestration"; + +export type { + AssistantChatMessage, + ChatCompletionRequest, + ChatCompletionTool, + ChatMessage, + DeveloperChatMessage, + DocumentTranslationApplyToSelector, + FunctionObject, + LlmModelDetails, + LlmModelParams, + OrchestrationConfigRef, + OrchestrationModuleConfig, + PromptTemplatingModule, + SystemChatMessage, + ToolChatMessage, + TranslationApplyToCategory, + TranslationInputParameters, + TranslationOutputParameters, + TranslationTargetLanguage, + UserChatMessage, +} from "@sap-ai-sdk/orchestration"; + +export { + OrchestrationEmbeddingResponse, + OrchestrationResponse, + OrchestrationStream, + OrchestrationStreamChunkResponse, + OrchestrationStreamResponse, +} from "@sap-ai-sdk/orchestration"; diff --git a/src/types/completion-request.ts b/src/types/completion-request.ts deleted file mode 100644 index fc5f814..0000000 --- a/src/types/completion-request.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Re-export types from SAP AI SDK for convenience. - * - * Note: With the migration to @sap-ai-sdk/orchestration, most request/response - * handling is now managed by the SDK internally. These types are provided - * for reference and advanced customization scenarios. - */ -export type { - OrchestrationModuleConfig, - ChatCompletionRequest, - PromptTemplatingModule, - MaskingModule, - FilteringModule, - GroundingModule, - TranslationModule, - LlmModelParams, - LlmModelDetails, - ChatCompletionTool, - FunctionObject, -} from "@sap-ai-sdk/orchestration"; diff --git a/src/types/completion-response.ts b/src/types/completion-response.ts deleted file mode 100644 index 56ba953..0000000 --- a/src/types/completion-response.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Re-export types from SAP AI SDK for convenience. - * - * Note: With the migration to @sap-ai-sdk/orchestration, most request/response - * handling is now managed by the SDK internally. These types are provided - * for reference and advanced customization scenarios. - */ -export type { - ChatMessage, - SystemChatMessage, - UserChatMessage, - AssistantChatMessage, - ToolChatMessage, - DeveloperChatMessage, -} from "@sap-ai-sdk/orchestration"; - -export { - OrchestrationResponse, - OrchestrationStreamResponse, - OrchestrationStreamChunkResponse, -} from "@sap-ai-sdk/orchestration"; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..64b04b8 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,10 @@ +/** + * Package version injected at build time via tsup define. + * @module + */ + +declare const __PACKAGE_VERSION__: string | undefined; + +/** Current package version from package.json, injected at build time. */ +export const VERSION: string = + typeof __PACKAGE_VERSION__ !== "undefined" ? __PACKAGE_VERSION__ : "0.0.0-test"; diff --git a/test-quick.ts b/test-quick.ts index 7b5a6b7..f6a42ff 100644 --- a/test-quick.ts +++ b/test-quick.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * Quick test script for SAP AI Provider v2 + * Quick test script for SAP AI Provider * * Usage: npx tsx test-quick.ts * @@ -8,19 +8,21 @@ */ import "dotenv/config"; -import { createSAPAIProvider } from "./src/index"; import { generateText } from "ai"; +import { createSAPAIProvider } from "./src/index"; + +/** + * + */ async function quickTest() { - console.log("🧪 Quick Test: SAP AI Provider v2\n"); + console.log("🧪 Quick Test: SAP AI Provider\n"); // Check for credentials if (!process.env.AICORE_SERVICE_KEY) { console.error("❌ AICORE_SERVICE_KEY environment variable is not set!"); console.error("\nSet it in .env file:"); - console.error( - 'AICORE_SERVICE_KEY=\'{"serviceurls":{"AI_API_URL":"..."},...}\'', - ); + console.error('AICORE_SERVICE_KEY=\'{"serviceurls":{"AI_API_URL":"..."},...}\''); process.exit(1); } @@ -32,7 +34,7 @@ async function quickTest() { console.log("✅ Provider created (synchronously!)"); console.log("\n📝 Testing gpt-4o..."); - const { text, usage, finishReason } = await generateText({ + const { finishReason, text, usage } = await generateText({ model: provider("gpt-4o"), prompt: "Say 'Hello from SAP AI Core!' in exactly those words.", }); diff --git a/tsconfig.json b/tsconfig.json index fc99222..0a075da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,11 @@ "lib": ["ES2022"], "esModuleInterop": true, "strict": true, + "noUncheckedIndexedAccess": true, "skipLibCheck": true, "declaration": true, "outDir": "dist" }, - "include": ["src", "tests", "examples", "test-quick.ts", "*.config.ts"], + "include": ["src", "scripts", "tests", "examples", "test-quick.ts", "*.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/tsup.config.ts b/tsup.config.ts index 51a9a4f..9d0050e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,9 +2,15 @@ import { defineConfig } from "tsup"; export default defineConfig([ { + clean: true, + define: { + __PACKAGE_VERSION__: JSON.stringify( + (await import("./package.json", { with: { type: "json" } })).default.version, + ), + }, + dts: true, entry: ["src/index.ts"], format: ["cjs", "esm"], - dts: true, sourcemap: true, }, ]); diff --git a/vitest.edge.config.js b/vitest.edge.config.ts similarity index 72% rename from vitest.edge.config.js rename to vitest.edge.config.ts index a74c977..7bd5812 100644 --- a/vitest.edge.config.js +++ b/vitest.edge.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "edge-runtime", - include: ["**/*.test.ts", "**/*.test.tsx"], + include: ["**/*.test.ts"], }, }); diff --git a/vitest.node.config.js b/vitest.node.config.js deleted file mode 100644 index 8de5970..0000000 --- a/vitest.node.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["**/*.test.ts", "**/*.test.tsx"], - }, -}); diff --git a/vitest.node.config.ts b/vitest.node.config.ts new file mode 100644 index 0000000..8ad76c1 --- /dev/null +++ b/vitest.node.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + exclude: ["src/**/*.test.ts", "node_modules/**", "dist/**"], + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "html", "lcov"], + thresholds: { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + }, + environment: "node", + include: ["**/*.test.ts"], + }, +});