UNPKG

watsonx-ai-provider

Version:

IBM watsonx.ai provider for the Vercel AI SDK - access Granite, OpenAI, Llama, Mistral, and other foundation models

1 lines 68.9 kB
{"version":3,"sources":["../src/watsonx-provider.ts","../src/watsonx-chat-language-model.ts","../src/watsonx-chat-settings.ts","../src/watsonx-config.ts","../src/watsonx-iam.ts","../src/watsonx-schemas.ts","../src/watsonx-error.ts","../src/watsonx-chat-messages.ts","../src/watsonx-prepare-tools.ts","../src/watsonx-chat-helpers.ts","../src/watsonx-embedding-model.ts","../src/version.ts"],"sourcesContent":["import {\n type LanguageModelV3,\n type EmbeddingModelV3,\n NoSuchModelError,\n type ProviderV3,\n} from '@ai-sdk/provider';\nimport {\n type FetchFunction,\n withUserAgentSuffix,\n withoutTrailingSlash,\n} from '@ai-sdk/provider-utils';\nimport { WatsonxChatLanguageModel } from './watsonx-chat-language-model';\nimport type { WatsonxChatModelId } from './watsonx-chat-settings';\nimport { WatsonxEmbeddingModel } from './watsonx-embedding-model';\nimport type { WatsonxEmbeddingModelId, WatsonxEmbeddingSettings } from './watsonx-embedding-settings';\nimport { VERSION as PROVIDER_VERSION } from './version';\n\nexport interface WatsonxProviderSettings {\n /**\n * IBM Cloud API key. Defaults to WATSONX_AI_APIKEY env variable.\n * Note: validation is lazy — a missing key does not throw until the first\n * model call, so `createWatsonx()` succeeds even without credentials.\n */\n apiKey?: string;\n\n /**\n * watsonx.ai project ID. Defaults to WATSONX_AI_PROJECT_ID env variable.\n * Lazily validated; see apiKey note.\n */\n projectId?: string;\n\n /**\n * Base URL for the watsonx.ai API.\n * Defaults to https://us-south.ml.cloud.ibm.com\n */\n baseURL?: string;\n\n /**\n * Custom headers to include in requests.\n */\n headers?: Record<string, string>;\n\n /**\n * Custom fetch implementation. Use to intercept requests for testing,\n * middleware, proxying, or telemetry.\n */\n fetch?: FetchFunction;\n\n /**\n * Custom ID generator for stream text/reasoning parts. Use to inject a\n * deterministic generator in tests; defaults to provider-utils `generateId`.\n */\n generateId?: () => string;\n}\n\nexport interface WatsonxProvider extends ProviderV3 {\n (modelId: WatsonxChatModelId): LanguageModelV3;\n\n chat(modelId: WatsonxChatModelId): LanguageModelV3;\n\n languageModel(modelId: WatsonxChatModelId): LanguageModelV3;\n\n embeddingModel(modelId: WatsonxEmbeddingModelId): EmbeddingModelV3;\n\n /** @deprecated Use `embeddingModel` instead. */\n textEmbeddingModel(\n modelId: WatsonxEmbeddingModelId,\n settings?: WatsonxEmbeddingSettings\n ): EmbeddingModelV3;\n}\n\nexport function createWatsonx(options: WatsonxProviderSettings = {}): WatsonxProvider {\n const baseURL =\n withoutTrailingSlash(options.baseURL) ?? 'https://us-south.ml.cloud.ibm.com';\n\n const getApiKey = () => {\n const apiKey = options.apiKey ?? process.env.WATSONX_AI_APIKEY;\n if (!apiKey) {\n throw new Error(\n 'IBM Cloud API key is required. Set WATSONX_AI_APIKEY env variable or pass apiKey option.'\n );\n }\n return apiKey;\n };\n\n const getProjectId = () => {\n const projectId = options.projectId ?? process.env.WATSONX_AI_PROJECT_ID;\n if (!projectId) {\n throw new Error(\n 'watsonx.ai project ID is required. Set WATSONX_AI_PROJECT_ID env variable or pass projectId option.'\n );\n }\n return projectId;\n };\n\n const getHeaders = () =>\n withUserAgentSuffix(\n { ...options.headers },\n `ai-sdk/watsonx/${PROVIDER_VERSION}`\n );\n\n const createChatModel = (modelId: WatsonxChatModelId): LanguageModelV3 => {\n return new WatsonxChatLanguageModel(modelId, {\n provider: 'watsonx.chat',\n baseURL,\n apiKey: getApiKey,\n projectId: getProjectId,\n headers: getHeaders,\n fetch: options.fetch,\n generateId: options.generateId,\n });\n };\n\n const createEmbeddingModel = (\n modelId: WatsonxEmbeddingModelId,\n settings: WatsonxEmbeddingSettings = {}\n ): EmbeddingModelV3 => {\n return new WatsonxEmbeddingModel(modelId, settings, {\n provider: 'watsonx.embedding',\n baseURL,\n apiKey: getApiKey,\n projectId: getProjectId,\n headers: getHeaders,\n fetch: options.fetch,\n });\n };\n\n const provider = function (modelId: WatsonxChatModelId): LanguageModelV3 {\n if (new.target) {\n throw new Error(\n 'The watsonx model function cannot be called with the `new` keyword.'\n );\n }\n return createChatModel(modelId);\n };\n\n provider.specificationVersion = 'v3' as const;\n provider.chat = createChatModel;\n provider.languageModel = createChatModel;\n provider.embeddingModel = createEmbeddingModel;\n provider.textEmbeddingModel = createEmbeddingModel;\n provider.imageModel = (modelId: string) => {\n throw new NoSuchModelError({ modelId, modelType: 'imageModel' });\n };\n\n return provider as WatsonxProvider;\n}\n\n/**\n * Default watsonx provider instance. Reads credentials from WATSONX_AI_APIKEY\n * and WATSONX_AI_PROJECT_ID env variables.\n */\nexport const watsonx = createWatsonx();\n","import {\n type LanguageModelV3,\n type LanguageModelV3CallOptions,\n type LanguageModelV3GenerateResult,\n type LanguageModelV3StreamResult,\n type LanguageModelV3StreamPart,\n type LanguageModelV3Content,\n type LanguageModelV3FinishReason,\n type LanguageModelV3Usage,\n type SharedV3Warning,\n APICallError,\n} from '@ai-sdk/provider';\nimport {\n postJsonToApi,\n createJsonResponseHandler,\n createEventSourceResponseHandler,\n generateId as defaultGenerateId,\n parseProviderOptions,\n combineHeaders,\n type ParseResult,\n} from '@ai-sdk/provider-utils';\nimport {\n type WatsonxChatModelId,\n watsonxLanguageModelOptions,\n} from './watsonx-chat-settings';\nimport { type WatsonxConfig, WATSONX_API_VERSION } from './watsonx-config';\nimport { getIAMToken, invalidateIAMToken } from './watsonx-iam';\nimport { watsonxChatResponseSchema, watsonxChatChunkSchema } from './watsonx-schemas';\nimport { z } from 'zod';\nimport { watsonxErrorHandler } from './watsonx-error';\nimport { convertToWatsonxMessages } from './watsonx-chat-messages';\nimport { prepareWatsonxTools } from './watsonx-prepare-tools';\nimport {\n convertFinishReason,\n convertUsage,\n getResponseMetadata,\n parseToolCallArgs,\n} from './watsonx-chat-helpers';\n\n// --- Main Model Class ---\n\nexport class WatsonxChatLanguageModel implements LanguageModelV3 {\n readonly specificationVersion = 'v3' as const;\n\n readonly modelId: WatsonxChatModelId;\n private readonly config: WatsonxConfig;\n\n // Vision-capable wx models accept https image URLs directly; non-vision\n // models will surface an error from the backend when sent one.\n readonly supportedUrls: Record<string, RegExp[]> = {\n 'image/*': [/^https?:\\/\\/.*$/],\n };\n\n constructor(modelId: WatsonxChatModelId, config: WatsonxConfig) {\n this.modelId = modelId;\n this.config = config;\n }\n\n get provider(): string {\n return this.config.provider;\n }\n\n private get generateId(): () => string {\n return this.config.generateId ?? defaultGenerateId;\n }\n\n private async getArgs(options: LanguageModelV3CallOptions): Promise<{\n body: Record<string, unknown>;\n warnings: SharedV3Warning[];\n }> {\n const warnings: SharedV3Warning[] = [];\n\n // Check for unsupported features\n if (options.frequencyPenalty != null) {\n warnings.push({ type: 'unsupported', feature: 'frequencyPenalty' });\n }\n\n if (options.presencePenalty != null) {\n warnings.push({ type: 'unsupported', feature: 'presencePenalty' });\n }\n\n if (options.seed != null) {\n warnings.push({ type: 'unsupported', feature: 'seed' });\n }\n\n const watsonxOptions = await parseProviderOptions({\n provider: 'watsonx',\n providerOptions: options.providerOptions,\n schema: watsonxLanguageModelOptions,\n });\n\n // Build messages\n const messages = convertToWatsonxMessages(options);\n\n // Build base request body\n const body: Record<string, unknown> = {\n model_id: this.modelId,\n project_id: this.config.projectId(),\n messages,\n max_tokens: options.maxOutputTokens,\n };\n\n if (options.temperature != null) {\n body.temperature = options.temperature;\n }\n if (options.topP != null) {\n body.top_p = options.topP;\n }\n if (options.topK != null) {\n body.top_k = options.topK;\n }\n if (options.stopSequences && options.stopSequences.length > 0) {\n // wx.ai typically caps stop_sequences around 6; warn past a conservative 4.\n const WX_STOP_SEQUENCE_SOFT_CAP = 4;\n if (options.stopSequences.length > WX_STOP_SEQUENCE_SOFT_CAP) {\n warnings.push({\n type: 'other',\n message: `stopSequences has ${options.stopSequences.length} entries; wx.ai may reject more than ${WX_STOP_SEQUENCE_SOFT_CAP} depending on the model family.`,\n });\n }\n body.stop_sequences = options.stopSequences;\n }\n if (watsonxOptions?.timeLimit != null) {\n body.time_limit = watsonxOptions.timeLimit;\n }\n // Reasoning models (e.g. openai/gpt-oss-*) accept the OpenAI-style flat\n // `reasoning_effort` field. Verified to scale reasoning length monotonically\n // (baseline → low → medium → high) on gpt-oss-120b. Quarkus's `thinking`\n // block is silently ignored on wx.ai-hosted models, so we use this shape.\n if (watsonxOptions?.reasoningEffort != null) {\n body.reasoning_effort = watsonxOptions.reasoningEffort;\n }\n\n // responseFormat: wx.ai supports { type: 'json_object' } and json_schema.\n if (options.responseFormat?.type === 'json') {\n if (options.responseFormat.schema != null) {\n body.response_format = {\n type: 'json_schema',\n json_schema: {\n name: options.responseFormat.name ?? 'response',\n description: options.responseFormat.description,\n schema: options.responseFormat.schema,\n },\n };\n } else {\n body.response_format = { type: 'json_object' };\n }\n }\n\n const { tools, toolChoice, toolWarnings } = prepareWatsonxTools(options);\n warnings.push(...toolWarnings);\n if (tools && tools.length > 0) {\n body.tools = tools;\n if (toolChoice) Object.assign(body, toolChoice);\n if (watsonxOptions?.parallelToolCalls != null) {\n body.parallel_tool_calls = watsonxOptions.parallelToolCalls;\n }\n }\n\n return { body, warnings };\n }\n\n async doGenerate(\n options: LanguageModelV3CallOptions\n ): Promise<LanguageModelV3GenerateResult> {\n const { body, warnings } = await this.getArgs(options);\n const apiKey = this.config.apiKey();\n\n const call = async () => {\n const token = await getIAMToken(apiKey);\n return postJsonToApi({\n url: `${this.config.baseURL}/ml/v1/text/chat?version=${WATSONX_API_VERSION}`,\n headers: combineHeaders(\n { Authorization: `Bearer ${token}` },\n this.config.headers(),\n options.headers\n ),\n body,\n failedResponseHandler: watsonxErrorHandler,\n successfulResponseHandler: createJsonResponseHandler(watsonxChatResponseSchema),\n abortSignal: options.abortSignal,\n fetch: this.config.fetch,\n });\n };\n\n let value: Awaited<ReturnType<typeof call>>['value'];\n let rawValue: Awaited<ReturnType<typeof call>>['rawValue'];\n let responseHeaders: Awaited<ReturnType<typeof call>>['responseHeaders'];\n try {\n ({ value, rawValue, responseHeaders } = await call());\n } catch (error) {\n // One-shot retry on 401 with a fresh IAM token — cached token may have\n // been revoked mid-TTL.\n if (\n APICallError.isInstance(error) &&\n error.statusCode === 401\n ) {\n invalidateIAMToken(apiKey);\n ({ value, rawValue, responseHeaders } = await call());\n } else {\n throw error;\n }\n }\n const response = value;\n\n const choice = response.choices[0];\n if (!choice) {\n throw new APICallError({\n message: 'No choices returned from watsonx.ai',\n url: `${this.config.baseURL}/ml/v1/text/chat?version=${WATSONX_API_VERSION}`,\n requestBodyValues: body,\n isRetryable: false,\n });\n }\n\n // Build content array\n const content: LanguageModelV3Content[] = [];\n\n // Reasoning models (e.g. openai/gpt-oss-*) surface chain-of-thought as\n // reasoning_content alongside the final answer. Emit it before text so\n // the SDK delivers it as a `reasoning` content part — same handling as\n // streaming `reasoning-delta` events.\n if (choice.message.reasoning_content) {\n content.push({ type: 'reasoning', text: choice.message.reasoning_content });\n }\n\n if (choice.message.content) {\n content.push({ type: 'text', text: choice.message.content });\n }\n\n if (choice.message.tool_calls) {\n for (const tc of choice.message.tool_calls) {\n content.push({\n type: 'tool-call',\n toolCallId: tc.id,\n toolName: tc.function.name,\n input: parseToolCallArgs(tc.function.arguments, this.modelId),\n });\n }\n }\n\n const hasToolCalls = (choice.message.tool_calls?.length ?? 0) > 0;\n\n return {\n content,\n finishReason: convertFinishReason(choice.finish_reason ?? 'stop', hasToolCalls),\n usage: convertUsage(response.usage),\n request: { body },\n response: {\n ...getResponseMetadata(response),\n headers: responseHeaders,\n body: rawValue ?? response,\n },\n warnings,\n };\n }\n\n async doStream(\n options: LanguageModelV3CallOptions\n ): Promise<LanguageModelV3StreamResult> {\n const { body, warnings } = await this.getArgs(options);\n const apiKey = this.config.apiKey();\n const generateId = this.generateId;\n\n // Stall watchdog: aborts the upstream fetch if no SSE chunk arrives within\n // the window. wx.ai can silently hang on SSE; we need a side-channel kicker.\n // Composed with the caller's signal so the SDK's abort flow sees both.\n const INITIAL_STALL_MS = 120_000; // reasoning models think before streaming\n const STREAMING_STALL_MS = 30_000; // between chunks once content has started\n const stallController = new AbortController();\n const composedSignal = options.abortSignal\n ? AbortSignal.any([options.abortSignal, stallController.signal])\n : stallController.signal;\n\n const call = async () => {\n const token = await getIAMToken(apiKey);\n return postJsonToApi({\n url: `${this.config.baseURL}/ml/v1/text/chat_stream?version=${WATSONX_API_VERSION}`,\n headers: combineHeaders(\n { Authorization: `Bearer ${token}` },\n this.config.headers(),\n options.headers\n ),\n body,\n failedResponseHandler: watsonxErrorHandler,\n successfulResponseHandler: createEventSourceResponseHandler(\n watsonxChatChunkSchema\n ),\n abortSignal: composedSignal,\n fetch: this.config.fetch,\n });\n };\n\n let result: Awaited<ReturnType<typeof call>>;\n try {\n result = await call();\n } catch (error) {\n if (APICallError.isInstance(error) && error.statusCode === 401) {\n invalidateIAMToken(apiKey);\n result = await call();\n } else {\n throw error;\n }\n }\n const { value: source, responseHeaders } = result;\n\n let usage: LanguageModelV3Usage = {\n inputTokens: { total: 0, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },\n outputTokens: { total: 0, text: undefined, reasoning: undefined },\n };\n let finishReason: LanguageModelV3FinishReason = {\n unified: 'other',\n raw: undefined,\n };\n const toolCallsInProgress = new Map<\n number,\n { id: string; name: string; args: string }\n >();\n let textId: string | null = null;\n let reasoningId: string | null = null;\n let sentResponseMetadata = false;\n let receivedContent = false;\n let finalized = false;\n let responseId: string | undefined;\n let receivedFinishReason = false;\n\n let stallTimer: ReturnType<typeof setTimeout> | undefined;\n const armStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n const ms = receivedContent ? STREAMING_STALL_MS : INITIAL_STALL_MS;\n stallTimer = setTimeout(() => {\n stallController.abort(\n new Error(\n `watsonx stream stalled: no data for ${ms / 1000}s (contentStarted=${receivedContent})`\n )\n );\n }, ms);\n };\n const clearStall = () => {\n if (stallTimer) {\n clearTimeout(stallTimer);\n stallTimer = undefined;\n }\n };\n\n const finalize = (\n controller: TransformStreamDefaultController<LanguageModelV3StreamPart>\n ) => {\n if (finalized) return;\n finalized = true;\n clearStall();\n\n if (reasoningId !== null) {\n controller.enqueue({ type: 'reasoning-end', id: reasoningId });\n }\n if (textId !== null) {\n controller.enqueue({ type: 'text-end', id: textId });\n }\n\n for (const [, tc] of toolCallsInProgress) {\n controller.enqueue({ type: 'tool-input-end', id: tc.id });\n controller.enqueue({\n type: 'tool-call',\n toolCallId: tc.id,\n toolName: tc.name,\n input: parseToolCallArgs(tc.args, this.modelId),\n });\n }\n\n // Override finish reason when tool calls are present — gpt-oss reports\n // 'stop' instead of 'tool_calls' for tool-call responses.\n const finalFinishReason =\n toolCallsInProgress.size > 0 && finishReason.unified !== 'tool-calls'\n ? { unified: 'tool-calls' as const, raw: finishReason.raw }\n : finishReason;\n\n // Anomaly: wx.ai billed completion tokens but nothing reached the\n // consumer. Known symptom of mistral-medium-2505 streaming tool-calls.\n // Always log so production instances surface it for bug reports.\n const completionTokens = usage.outputTokens?.total ?? 0;\n if (!receivedContent && completionTokens > 0) {\n console.warn(\n `[watsonx] stream anomaly: model=${this.modelId} response_id=${responseId ?? 'unknown'} ` +\n `reported completion_tokens=${completionTokens} but streamed no content/tool_calls. ` +\n `Likely a wx.ai streaming bug. Finish reason received: ${receivedFinishReason}.`\n );\n }\n\n controller.enqueue({ type: 'finish', finishReason: finalFinishReason, usage });\n };\n\n const stream = source.pipeThrough(\n new TransformStream<\n ParseResult<z.infer<typeof watsonxChatChunkSchema>>,\n LanguageModelV3StreamPart\n >({\n start(controller) {\n controller.enqueue({ type: 'stream-start', warnings });\n armStall();\n },\n\n transform(chunk, controller) {\n if (finalized) return;\n armStall();\n\n // Opt-in per-chunk trace for bug reports. Set WATSONX_DEBUG_STREAM=1\n // to capture raw + validated chunk shapes to the server console.\n if (process.env.WATSONX_DEBUG_STREAM) {\n if (chunk.success) {\n console.log('[watsonx chunk]', JSON.stringify(chunk.rawValue));\n } else {\n console.log(\n '[watsonx chunk parse-fail]',\n chunk.error,\n 'raw:',\n JSON.stringify(chunk.rawValue)\n );\n }\n }\n\n if (options.includeRawChunks) {\n controller.enqueue({ type: 'raw', rawValue: chunk.rawValue });\n }\n\n if (!chunk.success) {\n // Schema drift or malformed chunk. Surface as a stream error rather\n // than silently dropping — consumers can decide how to react.\n controller.enqueue({ type: 'error', error: chunk.error });\n return;\n }\n\n const value = chunk.value;\n\n if (!sentResponseMetadata) {\n sentResponseMetadata = true;\n const meta = getResponseMetadata(value);\n responseId = meta.id;\n controller.enqueue({\n type: 'response-metadata',\n ...meta,\n timestamp: meta.timestamp ?? new Date(),\n });\n }\n\n if (value.usage != null) {\n usage = convertUsage(value.usage);\n }\n\n const choice = value.choices?.[0];\n if (!choice) return;\n\n if (choice.delta?.reasoning_content) {\n receivedContent = true;\n if (reasoningId === null) {\n reasoningId = `reasoning-${generateId()}`;\n controller.enqueue({ type: 'reasoning-start', id: reasoningId });\n }\n controller.enqueue({\n type: 'reasoning-delta',\n id: reasoningId,\n delta: choice.delta.reasoning_content,\n });\n }\n\n if (choice.delta?.content) {\n receivedContent = true;\n if (textId === null) {\n textId = `text-${generateId()}`;\n controller.enqueue({ type: 'text-start', id: textId });\n }\n controller.enqueue({\n type: 'text-delta',\n id: textId,\n delta: choice.delta.content,\n });\n }\n\n if (choice.delta?.tool_calls) {\n receivedContent = true;\n for (const tc of choice.delta.tool_calls) {\n const toolIndex = tc.index ?? 0;\n let existing = toolCallsInProgress.get(toolIndex);\n if (!existing) {\n existing = {\n id: tc.id ?? `tool-${toolIndex}`,\n name: '',\n args: '',\n };\n toolCallsInProgress.set(toolIndex, existing);\n }\n if (tc.id) existing.id = tc.id;\n if (tc.function?.name && existing.name === '') {\n existing.name = tc.function.name;\n controller.enqueue({\n type: 'tool-input-start',\n id: existing.id,\n toolName: existing.name,\n });\n }\n if (tc.function?.arguments) {\n existing.args += tc.function.arguments;\n controller.enqueue({\n type: 'tool-input-delta',\n id: existing.id,\n delta: tc.function.arguments,\n });\n }\n }\n }\n\n if (choice.finish_reason) {\n finishReason = convertFinishReason(choice.finish_reason);\n receivedFinishReason = true;\n // Emit cleanup and finish now — some wx models don't close the SSE\n // connection after finish_reason. terminate() closes the readable\n // side cleanly so consumers see done without waiting for timeout.\n finalize(controller);\n controller.terminate();\n }\n },\n\n flush(controller) {\n finalize(controller);\n },\n })\n );\n\n return {\n stream,\n request: { body },\n response: { headers: responseHeaders },\n };\n }\n}\n","import { z } from 'zod';\n\nexport type WatsonxChatModelId =\n // OpenAI\n | 'openai/gpt-oss-120b'\n // IBM Granite models\n | 'ibm/granite-4-h-small'\n | 'ibm/granite-8b-code-instruct'\n // Meta Llama models\n | 'meta-llama/llama-3-3-70b-instruct'\n | 'meta-llama/llama-3-2-11b-vision-instruct'\n | 'meta-llama/llama-3-2-3b-instruct'\n | 'meta-llama/llama-3-2-1b-instruct'\n // Mistral models\n // Note: mistral-medium-2505 has a known wx.ai streaming bug where tool-call\n // tokens are billed but never transmitted in SSE deltas. Route mistral\n // tool-calling workloads through doGenerate (non-streaming) or pick a\n // different family until IBM fixes it.\n | 'mistralai/mistral-medium-2505'\n | 'mistralai/mistral-small-3-1-24b-instruct-2503'\n | 'mistralai/pixtral-12b'\n // Allow any model ID\n | (string & {});\n\n/**\n * watsonx.ai-specific options passed via `providerOptions.watsonx` on a call.\n * Standard generation parameters (temperature, topP, topK, maxOutputTokens,\n * stopSequences) live on LanguageModelV3CallOptions and should be passed there.\n */\nexport const watsonxLanguageModelOptions = z.object({\n /**\n * Maximum wall-clock time in milliseconds the server will spend on this\n * request before aborting. Unique to watsonx.ai.\n */\n timeLimit: z.number().optional(),\n\n /**\n * Whether the model may call multiple tools in parallel within one response.\n * Forwarded to wx.ai as `parallel_tool_calls`. Defaults to the model's native\n * behavior (usually true) when unset.\n */\n parallelToolCalls: z.boolean().optional(),\n\n /**\n * Reasoning effort for reasoning-capable models. Forwarded to wx.ai as\n * `reasoning_effort`. Verified to scale chain-of-thought length on\n * `openai/gpt-oss-120b` (HIGH produces ~3× the reasoning of LOW).\n *\n * Models without reasoning capability (e.g. `ibm/granite-4-h-small`)\n * accept the field but produce no `reasoning_content` regardless.\n */\n reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),\n});\n\nexport type WatsonxLanguageModelOptions = z.infer<\n typeof watsonxLanguageModelOptions\n>;\n","import type { FetchFunction } from '@ai-sdk/provider-utils';\n\n// API version for watsonx.ai endpoints. Matches the value shipped in the\n// ibm-watsonx-ai Python SDK (utils/API_VERSION_PARAM); bump when IBM publishes\n// a newer release we want to opt into.\nexport const WATSONX_API_VERSION = '2026-04-20';\n\nexport interface WatsonxConfig {\n provider: string;\n baseURL: string;\n apiKey: () => string;\n projectId: () => string;\n headers: () => Record<string, string>;\n fetch?: FetchFunction;\n generateId?: () => string;\n}\n","import { LoadAPIKeyError } from '@ai-sdk/provider';\n\n// Cache tokens per API key to support multiple provider instances\nconst tokenCache = new Map<string, { token: string; expiresAt: number }>();\n\n// Track in-flight token requests to prevent duplicate fetches\nconst pendingRequests = new Map<string, Promise<string>>();\n\nexport async function getIAMToken(apiKey: string): Promise<string> {\n // Check cache with 5 min buffer\n const cached = tokenCache.get(apiKey);\n if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1000) {\n return cached.token;\n }\n\n // Check if there's already a pending request for this API key\n const pending = pendingRequests.get(apiKey);\n if (pending) {\n return pending;\n }\n\n // Create and track the token fetch promise\n const fetchPromise = fetchIAMToken(apiKey);\n pendingRequests.set(apiKey, fetchPromise);\n\n try {\n return await fetchPromise;\n } finally {\n pendingRequests.delete(apiKey);\n }\n}\n\nasync function fetchIAMToken(apiKey: string): Promise<string> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 15000);\n let response: Response;\n try {\n response = await fetch('https://iam.cloud.ibm.com/identity/token', {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ibm:params:oauth:grant-type:apikey',\n apikey: apiKey,\n }),\n signal: controller.signal,\n });\n } finally {\n clearTimeout(timeoutId);\n }\n\n if (!response.ok) {\n const text = await response.text();\n throw new LoadAPIKeyError({\n message: `Failed to get IBM IAM token: ${response.status} - ${text}`,\n });\n }\n\n const data = (await response.json()) as {\n access_token: string;\n expires_in: number;\n };\n\n tokenCache.set(apiKey, {\n token: data.access_token,\n expiresAt: Date.now() + data.expires_in * 1000,\n });\n\n return data.access_token;\n}\n\n// For testing - allows clearing the cache\nexport function clearTokenCache(): void {\n tokenCache.clear();\n}\n\n// Invalidate the cached token for a specific API key. Call when the server\n// returns 401 so the next getIAMToken() re-fetches instead of re-sending the\n// revoked token.\nexport function invalidateIAMToken(apiKey: string): void {\n tokenCache.delete(apiKey);\n}\n","import { z } from 'zod';\n\n// --- Error Schema (shared) ---\n\nexport const watsonxErrorSchema = z\n .object({\n errors: z\n .array(\n z\n .object({\n code: z.string(),\n message: z.string(),\n more_info: z.string().optional(),\n })\n .loose()\n )\n .optional(),\n error: z.string().optional(),\n message: z.string().optional(),\n // wx.ai variants return either; coerce to one via preprocess.\n statusCode: z.number().optional(),\n })\n .loose()\n .transform((val) => {\n const raw = val as { status_code?: number };\n return { ...val, statusCode: val.statusCode ?? raw.status_code };\n });\n\n// --- Chat Response Schema ---\n// Top-level schema is strict so unknown fields surface in dev; nested objects\n// remain .loose() to tolerate forward-compatible additions from wx.ai.\n\nexport const watsonxChatResponseSchema = z.object({\n id: z.string().nullish(),\n model_id: z.string().nullish(),\n created: z.number().nullish(),\n choices: z.array(\n z\n .object({\n index: z.number(),\n message: z\n .object({\n role: z.literal('assistant'),\n content: z.string().nullish(),\n reasoning_content: z.string().nullish(),\n tool_calls: z\n .array(\n z\n .object({\n id: z.string(),\n type: z.literal('function'),\n function: z\n .object({\n name: z.string(),\n arguments: z.string(),\n })\n .loose(),\n })\n .loose()\n )\n .optional(),\n })\n .loose(),\n finish_reason: z.string().nullish(),\n })\n .loose()\n ),\n usage: z\n .object({\n prompt_tokens: z.number(),\n completion_tokens: z.number(),\n total_tokens: z.number(),\n completion_tokens_details: z\n .object({ reasoning_tokens: z.number().optional() })\n .loose()\n .optional(),\n })\n .loose(),\n});\n\n// --- Chat Stream Chunk Schema ---\n// Validates each SSE `data: {...}` payload. Fields are mostly optional because\n// deltas are sparse — a single chunk may carry only usage, only content, only\n// a tool_calls fragment, or only a finish_reason.\n\nexport const watsonxChatChunkSchema = z.object({\n id: z.string().nullish(),\n model_id: z.string().nullish(),\n created: z.number().nullish(),\n choices: z\n .array(\n z\n .object({\n index: z.number().optional(),\n delta: z\n .object({\n role: z.string().optional(),\n content: z.string().nullish(),\n reasoning_content: z.string().nullish(),\n tool_calls: z\n .array(\n z\n .object({\n index: z.number().optional(),\n id: z.string().optional(),\n type: z.string().optional(),\n function: z\n .object({\n name: z.string().optional(),\n arguments: z.string().optional(),\n })\n .loose()\n .optional(),\n })\n .loose()\n )\n .optional(),\n })\n .loose()\n .optional(),\n finish_reason: z.string().nullish(),\n })\n .loose()\n )\n .optional(),\n usage: z\n .object({\n prompt_tokens: z.number(),\n completion_tokens: z.number(),\n total_tokens: z.number().optional(),\n completion_tokens_details: z\n .object({ reasoning_tokens: z.number().optional() })\n .loose()\n .optional(),\n })\n .loose()\n .optional(),\n});\n\n// --- Embedding Response Schema ---\n\nexport const watsonxEmbeddingResponseSchema = z.object({\n model_id: z.string().nullish(),\n results: z.array(\n z\n .object({\n embedding: z.array(z.number()),\n input_token_count: z.number().optional(),\n })\n .loose()\n ),\n input_token_count: z.number().optional(),\n});\n","import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils';\nimport { watsonxErrorSchema } from './watsonx-schemas';\n\nexport const watsonxErrorHandler = createJsonErrorResponseHandler({\n errorSchema: watsonxErrorSchema,\n errorToMessage: (error) => {\n if (error.errors?.[0]?.message) {\n return error.errors[0].message;\n }\n return error.message ?? error.error ?? 'Unknown watsonx.ai error';\n },\n isRetryable: (response, error) => {\n // Rate limiting\n if (response.status === 429) return true;\n // Server errors\n if (response.status >= 500) return true;\n // Check error codes\n const code = error?.errors?.[0]?.code;\n if (code === 'rate_limit_exceeded' || code === 'service_unavailable')\n return true;\n return false;\n },\n});\n","import {\n type LanguageModelV3CallOptions,\n UnsupportedFunctionalityError,\n} from '@ai-sdk/provider';\nimport { convertUint8ArrayToBase64 } from '@ai-sdk/provider-utils';\n\n// --- API Types ---\n\ninterface WatsonxContentPart {\n type: 'text' | 'image_url';\n text?: string;\n image_url?: { url: string };\n}\n\nexport interface WatsonxMessage {\n role: 'system' | 'user' | 'assistant' | 'tool';\n content?: string | WatsonxContentPart[];\n tool_calls?: Array<{\n id: string;\n type: 'function';\n function: { name: string; arguments: string };\n }>;\n tool_call_id?: string;\n}\n\n// --- Message Conversion ---\n\nexport function convertToWatsonxMessages(\n options: LanguageModelV3CallOptions\n): WatsonxMessage[] {\n const messages: WatsonxMessage[] = [];\n\n for (const message of options.prompt) {\n switch (message.role) {\n case 'system':\n messages.push({ role: 'system', content: message.content });\n break;\n\n case 'user': {\n const parts: WatsonxContentPart[] = [];\n for (const part of message.content) {\n if (part.type === 'text') {\n parts.push({ type: 'text', text: part.text });\n } else if (part.type === 'file') {\n if (!part.mediaType.startsWith('image/')) {\n throw new UnsupportedFunctionalityError({\n functionality: `file parts with media type ${part.mediaType}`,\n message:\n 'watsonx.ai chat only supports image file parts; other media types are not supported.',\n });\n }\n let imageUrl: string;\n if (part.data instanceof URL) {\n imageUrl = part.data.toString();\n } else if (typeof part.data === 'string') {\n imageUrl =\n part.data.startsWith('data:') || part.data.startsWith('http')\n ? part.data\n : `data:${part.mediaType};base64,${part.data}`;\n } else if (part.data instanceof Uint8Array) {\n const base64 = convertUint8ArrayToBase64(part.data);\n imageUrl = `data:${part.mediaType};base64,${base64}`;\n } else {\n throw new UnsupportedFunctionalityError({\n functionality: 'file part with unknown data shape',\n });\n }\n parts.push({ type: 'image_url', image_url: { url: imageUrl } });\n }\n }\n // Collapse to a plain string when there's only one text part — keeps\n // requests compatible with wx models that don't accept content arrays.\n if (parts.length === 1 && parts[0].type === 'text') {\n messages.push({ role: 'user', content: parts[0].text ?? '' });\n } else {\n messages.push({ role: 'user', content: parts });\n }\n break;\n }\n\n case 'assistant': {\n const toolCalls: WatsonxMessage['tool_calls'] = [];\n let textContent = '';\n for (const part of message.content) {\n if (part.type === 'text') {\n textContent += part.text;\n } else if (part.type === 'tool-call') {\n toolCalls.push({\n id: part.toolCallId,\n type: 'function',\n function: {\n name: part.toolName,\n arguments:\n typeof part.input === 'string'\n ? part.input\n : JSON.stringify(part.input),\n },\n });\n }\n }\n const msg: WatsonxMessage = { role: 'assistant' };\n if (textContent) msg.content = textContent;\n if (toolCalls.length > 0) msg.tool_calls = toolCalls;\n messages.push(msg);\n break;\n }\n\n case 'tool':\n for (const part of message.content) {\n if (part.type === 'tool-result') {\n let resultContent: string;\n if (part.output.type === 'text') {\n resultContent = part.output.value;\n } else if (part.output.type === 'json') {\n resultContent = JSON.stringify(part.output.value);\n } else if (part.output.type === 'error-text') {\n resultContent = part.output.value;\n } else if (part.output.type === 'error-json') {\n resultContent = JSON.stringify(part.output.value);\n } else {\n resultContent = JSON.stringify(part.output);\n }\n messages.push({\n role: 'tool',\n tool_call_id: part.toolCallId,\n content: resultContent,\n });\n }\n }\n break;\n }\n }\n\n return messages;\n}\n","import {\n type LanguageModelV3CallOptions,\n type LanguageModelV3FunctionTool,\n type SharedV3Warning,\n} from '@ai-sdk/provider';\n\ninterface WatsonxToolDefinition {\n type: 'function';\n function: {\n name: string;\n description?: string;\n parameters: unknown;\n strict?: boolean;\n };\n}\n\ntype WatsonxToolChoice =\n | { tool_choice_option: 'auto' | 'none' | 'required' }\n | {\n tool_choice: {\n type: 'function';\n function: { name: string };\n };\n };\n\nexport interface PreparedTools {\n tools?: WatsonxToolDefinition[];\n toolChoice?: WatsonxToolChoice;\n toolWarnings: SharedV3Warning[];\n}\n\n/**\n * Maps the SDK's `tools` and `toolChoice` call options into the wx.ai request\n * shape. Surfaces warnings for non-function tools (wx.ai only supports\n * function tools) and forwards the optional `strict` flag when present.\n */\nexport function prepareWatsonxTools(\n options: Pick<LanguageModelV3CallOptions, 'tools' | 'toolChoice'>\n): PreparedTools {\n const toolWarnings: SharedV3Warning[] = [];\n\n if (!options.tools || options.tools.length === 0) {\n return { toolWarnings };\n }\n\n for (const tool of options.tools) {\n if (tool.type !== 'function') {\n toolWarnings.push({\n type: 'unsupported',\n feature: `tool type '${tool.type}'`,\n details: 'watsonx.ai only supports function tools',\n });\n }\n }\n\n const tools: WatsonxToolDefinition[] = options.tools\n .filter((tool): tool is LanguageModelV3FunctionTool => tool.type === 'function')\n .map((tool) => {\n const fn: WatsonxToolDefinition['function'] = {\n name: tool.name,\n description: tool.description,\n parameters: tool.inputSchema,\n };\n // Forward strict when present — wx.ai honours OpenAI's strict schema flag.\n const strict = (tool as { strict?: boolean }).strict;\n if (strict != null) fn.strict = strict;\n return { type: 'function', function: fn };\n });\n\n let toolChoice: WatsonxToolChoice | undefined;\n if (options.toolChoice) {\n switch (options.toolChoice.type) {\n case 'auto':\n case 'none':\n case 'required':\n toolChoice = { tool_choice_option: options.toolChoice.type };\n break;\n case 'tool':\n toolChoice = {\n tool_choice: {\n type: 'function',\n function: { name: options.toolChoice.toolName },\n },\n };\n break;\n }\n }\n\n return { tools, toolChoice, toolWarnings };\n}\n","import type {\n LanguageModelV3FinishReason,\n LanguageModelV3ResponseMetadata,\n LanguageModelV3Usage,\n} from '@ai-sdk/provider';\n\n/**\n * Extracts response metadata (id, modelId, timestamp) from a watsonx.ai chat\n * response or stream chunk. Both shapes include `id`, `model_id`, and `created`\n * (unix seconds). Missing fields surface as `undefined`.\n */\nexport function getResponseMetadata(value: {\n id?: string | null;\n model_id?: string | null;\n created?: number | null;\n}): LanguageModelV3ResponseMetadata {\n return {\n id: value.id ?? undefined,\n modelId: value.model_id ?? undefined,\n timestamp:\n typeof value.created === 'number'\n ? new Date(value.created * 1000)\n : undefined,\n };\n}\n\nexport function convertFinishReason(\n reason: string,\n hasToolCalls = false\n): LanguageModelV3FinishReason {\n let unified: LanguageModelV3FinishReason['unified'];\n switch (reason) {\n case 'stop':\n case 'eos_token':\n unified = 'stop';\n break;\n case 'length':\n case 'max_tokens':\n unified = 'length';\n break;\n case 'tool_calls':\n unified = 'tool-calls';\n break;\n case 'content_filter':\n unified = 'content-filter';\n break;\n default:\n unified = 'other';\n }\n // Infer tool-calls if tools are present but reason is unclear\n if (unified === 'other' && hasToolCalls) {\n unified = 'tool-calls';\n }\n return { unified, raw: reason };\n}\n\n// Granite models occasionally return tool-call arguments as a JSON-encoded\n// string wrapping the real JSON (e.g. \"\\\"{\\\\\\\"foo\\\\\\\": 1}\\\"\"). Unwrap once,\n// only for IBM Granite models — other providers can legitimately return a\n// JSON-encoded string that we shouldn't mutate.\n//\n// Despite the name, this does not fully parse the arguments — the SDK expects\n// a string/JSON value that it parses itself.\nexport function parseToolCallArgs(args: string, modelId?: string): string {\n if (modelId == null || !modelId.startsWith('ibm/granite')) {\n return args;\n }\n try {\n const parsed = JSON.parse(args);\n if (typeof parsed === 'string') {\n return parsed;\n }\n } catch {\n // Not valid JSON, keep original\n }\n return args;\n}\n\nexport function convertUsage(\n usage:\n | {\n prompt_tokens: number;\n completion_tokens: number;\n completion_tokens_details?: { reasoning_tokens?: number };\n }\n | null\n | undefined\n): LanguageModelV3Usage {\n if (usage == null) {\n return {\n inputTokens: {\n total: 0,\n noCache: undefined,\n cacheRead: undefined,\n cacheWrite: undefined,\n },\n outputTokens: { total: 0, text: undefined, reasoning: undefined },\n };\n }\n\n const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens;\n const textTokens =\n reasoningTokens != null\n ? Math.max(0, usage.completion_tokens - reasoningTokens)\n : usage.completion_tokens;\n\n return {\n inputTokens: {\n total: usage.prompt_tokens,\n noCache: undefined,\n cacheRead: undefined,\n cacheWrite: undefined,\n },\n outputTokens: {\n total: usage.completion_tokens,\n text: textTokens,\n reasoning: reasoningTokens,\n },\n raw: {\n prompt_tokens: usage.prompt_tokens,\n completion_tokens: usage.completion_tokens,\n },\n };\n}\n","import {\n type EmbeddingModelV3,\n type EmbeddingModelV3CallOptions,\n type EmbeddingModelV3Result,\n TooManyEmbeddingValuesForCallError,\n} from '@ai-sdk/provider';\nimport {\n postJsonToApi,\n createJsonResponseHandler,\n combineHeaders,\n} from '@ai-sdk/provider-utils';\nimport type {\n WatsonxEmbeddingModelId,\n WatsonxEmbeddingSettings,\n} from './watsonx-embedding-settings';\nimport { type WatsonxConfig, WATSONX_API_VERSION } from './watsonx-config';\nimport { getIAMToken } from './watsonx-iam';\nimport { watsonxEmbeddingResponseSchema } from './watsonx-schemas';\nimport { watsonxErrorHandler } from './watsonx-error';\n\n// --- Main Embedding Model Class ---\n\nexport class WatsonxEmbeddingModel implements EmbeddingModelV3 {\n readonly specificationVersion = 'v3' as const;\n\n readonly modelId: WatsonxEmbeddingModelId;\n readonly settings: WatsonxEmbeddingSettings;\n private readonly config: WatsonxConfig;\n\n /**\n * Maximum number of embeddings per API call.\n * watsonx.ai supports batching multiple texts in a single request.\n */\n readonly maxEmbeddingsPerCall = 100;\n\n /**\n * Whether the model supports parallel embedding calls.\n */\n readonly supportsParallelCalls = true;\n\n constructor(\n modelId: WatsonxEmbeddingModelId,\n settings: WatsonxEmbeddingSettings,\n config: WatsonxConfig\n ) {\n this.modelId = modelId;\n this.settings = settings;\n this.config = config;\n }\n\n get provider(): string {\n return this.config.provider;\n }\n\n async doEmbed(options: EmbeddingModelV3CallOptions): Promise<EmbeddingModelV3Result> {\n if (options.values.length > this.maxEmbeddingsPerCall) {\n throw new TooManyEmbeddingValuesForCallError({\n provider: this.provider,\n modelId: this.modelId,\n maxEmbeddingsPerCall: this.maxEmbeddingsPerCall,\n values: options.values,\n });\n }\n\n const token = await getIAMToken(this.config.apiKey());\n\n const body: Record<string, unknown> = {\n model_id: this.modelId,\n project_id: this.config.projectId(),\n inputs: options.values,\n };\n\n // Add truncation parameter if enabled\n if (this.settings.truncateInputTokens) {\n body.parameters = {\n truncate_input_tokens: true,\n };\n }\n\n const { value: response, rawValue, responseHeaders } = await postJsonToApi({\n url: `${this.config.baseURL}/ml/v1/text/embeddings?version=${WATSONX_API_VERSION}`,\n headers: combineHeaders(\n { Authorization: `Bearer ${token}` },\n this.config.headers(),\n options.headers\n ),\n body,\n failedResponseHandler: watsonxErrorHandler,\n successfulResponseHandler: createJsonResponseHandler(\n watsonxEmbeddingResponseSchema\n ),\n abortSignal: options.abortSignal,\n fetch: this.config.fetch,\n });\n\n // Calculate total tokens - prefer top-level count, fallback to summing results\n let totalTokens: number;\n if (response.input_token_count != null) {\n totalTokens = response.input_token_count;\n } else {\n totalTokens = 0;\n for (const result of response.results) {\n totalTokens += result.input_token_count ?? 0;\n }\n }\n\n return {\n embeddings: response.results.map((r) => r.embedding),\n usage: { tokens: totalTokens },\n response: {\n headers: responseHeaders,\n body: rawValue ?? response,\n },\n warnings: [],\n };\n }\n}\n","// Version of the watsonx-ai-provider package, injected at build time by tsup\n// (see tsup.config.ts `define`). Falls back to '0.0.0-test' when running\n// straight from source (tests, dev) where the constant isn't substituted.\ndeclare const __PACKAGE_VERSION__: string | undefined;\n\nexport const VERSION: string =\n typeof __PACKAGE_VERSION__ !== 'undefined' ? __PACKAGE_VERSION__ : '0.0.0-test';\n"],"mappings":";AAAA;AAAA,EAGE;AAAA,OAEK;AACP;AAAA,EAEE;AAAA,EACA;AAAA,OACK;;;ACVP;AAAA,EAUE;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,OAEK;;;ACpBP,SAAS,SAAS;AA6BX,IAAM,8BAA8B,EAAE,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA,EAKlD,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO/B,mBAAmB,EAAE,QAAQ,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUxC,iBAAiB,EAAE,KAAK,CAAC,OAAO,UAAU,MAAM,CAAC,EAAE,SAAS;AAC9D,CAAC;;;AC/CM,IAAM,sBAAsB;;;ACLnC,SAAS,uBAAuB;AAGhC,IAAM,aAAa,oBAAI,IAAkD;AAGzE,IAAM,kBAAkB,oBAAI,IAA6B;AAEzD,eAAsB,YAAY,QAAiC;AAEjE,QAAM,SAAS,WAAW,IAAI,MAAM;AACpC,MAAI,UAAU,OAAO,YAAY,KAAK,IAAI,IAAI,IAAI,KAAK,KAAM;AAC3D,WAAO,OAAO;AAAA,EAChB;AAGA,QAAM,UAAU,gBAAgB,IAAI,MAAM;AAC1C,MAAI,SAAS;AACX,WAAO;AAAA,EACT;AAGA,QAAM,eAAe,cAAc,MAAM;AACzC,kBAAgB,IAAI,QAAQ,YAAY;AAExC,MAAI;AACF,WAAO,MAAM;AAAA,EACf,UAAE;AACA,oBAAgB,OAAO,MAAM;AAAA,EAC/B;AACF;AAEA,eAAe,cAAc,QAAiC;AAC5D,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,IAAK;AAC5D,MAAI;AACJ,MAAI;AACF,eAAW,MAAM,MAAM,4CAA4C;AAAA,MACjE,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,MAC/D,MAAM,IAAI,gBAAgB;AAAA,QACxB,YAAY;AAAA,QACZ,QAAQ;AAAA,MACV,CAAC;AAAA,MACD,QAAQ,WAAW;AAAA,IACrB,CAAC;AAAA,EACH,UAAE;AACA,iBAAa,SAAS;AAAA,EACxB;AAEA,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,OAAO,MAAM,SAAS,KAAK;AACjC,UAAM,IAAI,gBAAgB;AAAA,MACxB,SAAS,gCAAgC,SAAS,M