From a977f51367ef4c2f4d6663b81be392b1dd4faada Mon Sep 17 00:00:00 2001 From: Matt Carvin <90224411+mcarvin8@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:05:11 -0400 Subject: [PATCH] feat(ai)!: migrate core to Vercel AI SDK Replace the `openai` dependency with `ai` + `@ai-sdk/*` providers and add env-driven dispatch for OpenAI, OpenAI-compatible, Anthropic, Google, Bedrock, Mistral, Cohere, Groq, xAI, and DeepSeek. Adds `llmModelProvider` injection hook, new `LLM_PROVIDER` env var, and provider SDKs as `optionalDependencies`. BREAKING CHANGE: removes `openAiClientProvider` and `OpenAiLikeClient` from the public API; use `llmModelProvider` with a Vercel AI SDK `LanguageModel` instead. --- README.md | 119 +++++++-- package-lock.json | 463 ++++++++++++++++++++++++++++++++-- package.json | 40 ++- src/ai/aiConstants.ts | 10 +- src/ai/aiSummary.ts | 91 ++++--- src/ai/aiTypes.ts | 16 +- src/ai/llmProviders.ts | 402 +++++++++++++++++++++++++++++ src/ai/openAIConfig.ts | 155 ------------ src/index.ts | 51 ++-- test/aiSummary.spec.ts | 260 ++++++++++--------- test/helpers/mockLlm.ts | 62 +++++ test/index.spec.ts | 34 +-- test/llmProviders.spec.ts | 425 +++++++++++++++++++++++++++++++ test/openAIConfig.spec.ts | 194 -------------- test/openAiSdk.spec.ts | 32 --- test/summarizeGitDiff.spec.ts | 26 +- tsconfig.json | 1 + 17 files changed, 1699 insertions(+), 682 deletions(-) create mode 100644 src/ai/llmProviders.ts delete mode 100644 src/ai/openAIConfig.ts create mode 100644 test/helpers/mockLlm.ts create mode 100644 test/llmProviders.spec.ts delete mode 100644 test/openAIConfig.spec.ts delete mode 100644 test/openAiSdk.spec.ts diff --git a/README.md b/README.md index 065a629..ad479b7 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ [![Maintainability](https://qlty.sh/gh/mcarvin8/projects/smart-diff/maintainability.svg)](https://qlty.sh/gh/mcarvin8/projects/smart-diff) [![codecov](https://codecov.io/gh/mcarvin8/smart-diff/graph/badge.svg?token=H3ZWAGG7S9)](https://codecov.io/gh/mcarvin8/smart-diff) -TypeScript library that turns a **git revision range** into a **Markdown summary** using an OpenAI-compatible Chat Completions API. It uses [`simple-git`](https://github.com/steveukx/git-js) to read the repo, respects **path includes/excludes** and **commit message include/exclude regexes**, and sends commits, paths, structured diff stats, and unified diff text to the model. +TypeScript library that turns a **git revision range** into a **Markdown summary** using any LLM provider supported by the [Vercel AI SDK](https://sdk.vercel.ai) — OpenAI, Anthropic, Google Gemini, Amazon Bedrock, Mistral, Cohere, Groq, xAI, DeepSeek, or any OpenAI-compatible gateway. It uses [`simple-git`](https://github.com/steveukx/git-js) to read the repo, respects **path includes/excludes** and **commit message include/exclude regexes**, and sends commits, paths, structured diff stats, and unified diff text to the model. ## Requirements - **Node.js** 20+ -- OpenAI Configuration -- [Git Bash](https://git-scm.com/install/) +- An LLM provider credential (see [Provider configuration](#provider-configuration)) +- [Git](https://git-scm.com/) on the `PATH` ## Installation @@ -20,27 +20,70 @@ TypeScript library that turns a **git revision range** into a **Markdown summary npm install @mcarvin/smart-diff ``` -## OpenAI Configuration +`@ai-sdk/openai` and `@ai-sdk/openai-compatible` ship as direct dependencies. Every other provider (`@ai-sdk/anthropic`, `@ai-sdk/google`, `@ai-sdk/amazon-bedrock`, `@ai-sdk/mistral`, `@ai-sdk/cohere`, `@ai-sdk/groq`, `@ai-sdk/xai`, `@ai-sdk/deepseek`) is declared as an **optional peer** and only needs to be installed when you actually use that provider. If the package is missing, smart-diff throws a clear error telling you which one to install. -The library is considered “configured” when `shouldUseLlmGateway()` is true: API key, base URL, and/or JSON default headers are set. Otherwise `summarizeGitDiff` / `generateSummary` throw with `LLM_GATEWAY_REQUIRED_MESSAGE` unless you pass **`openAiClientProvider`**. +## Provider configuration + +smart-diff is "configured" when [`isLlmProviderConfigured()`](#lower-level-api) returns true — i.e. at least one supported provider can be resolved from env vars — **or** you pass your own `llmModelProvider` factory. Otherwise `summarizeGitDiff` / `generateSummary` throw with `LLM_GATEWAY_REQUIRED_MESSAGE`. + +### Selecting a provider + +`LLM_PROVIDER` explicitly selects a provider. When unset, the resolver auto-detects in this order: `LLM_BASE_URL`/`OPENAI_BASE_URL` → `openai-compatible`, `OPENAI_API_KEY`/`LLM_API_KEY` → `openai`, then `ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY` (or `GOOGLE_API_KEY`), `MISTRAL_API_KEY`, `COHERE_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY`, and finally `OPENAI_DEFAULT_HEADERS`/`LLM_DEFAULT_HEADERS` → `openai`. + +| Provider (`LLM_PROVIDER`) | Package | Credential env vars | Default model | +|---|---|---|---| +| `openai` | `@ai-sdk/openai` | `OPENAI_API_KEY` or `LLM_API_KEY` | `gpt-4o-mini` | +| `openai-compatible` | `@ai-sdk/openai-compatible` | `LLM_BASE_URL` or `OPENAI_BASE_URL` (required); `OPENAI_API_KEY`/`LLM_API_KEY` or custom headers | `gpt-4o-mini` | +| `anthropic` | `@ai-sdk/anthropic` | `ANTHROPIC_API_KEY` | `claude-3-5-haiku-latest` | +| `google` | `@ai-sdk/google` | `GOOGLE_GENERATIVE_AI_API_KEY` or `GOOGLE_API_KEY` | `gemini-2.0-flash` | +| `bedrock` | `@ai-sdk/amazon-bedrock` | Standard AWS credential chain (env / profile / role) | `anthropic.claude-3-5-haiku-20241022-v1:0` | +| `mistral` | `@ai-sdk/mistral` | `MISTRAL_API_KEY` | `mistral-small-latest` | +| `cohere` | `@ai-sdk/cohere` | `COHERE_API_KEY` | `command-r-08-2024` | +| `groq` | `@ai-sdk/groq` | `GROQ_API_KEY` | `llama-3.1-8b-instant` | +| `xai` | `@ai-sdk/xai` | `XAI_API_KEY` | `grok-2-latest` | +| `deepseek` | `@ai-sdk/deepseek` | `DEEPSEEK_API_KEY` | `deepseek-chat` | > `LLM_*` wins over `OPENAI_*` where both exist. +### Common env vars + | Variable | Purpose | -|----------|---------| -| `OPENAI_API_KEY` or `LLM_API_KEY` | API key. Required by OpenAI client but can be null if gateway is set. | -| `OPENAI_BASE_URL` or `LLM_BASE_URL` | Base URL for an OpenAI-compatible gateway. | -| `OPENAI_DEFAULT_HEADERS` or `LLM_DEFAULT_HEADERS` | JSON object of extra headers; Can supply `Authorization` (e.g. raw `sk-…`) when no API key is set. | -| `OPENAI_MAX_DIFF_CHARS` or `LLM_MAX_DIFF_CHARS` | Max size of unified diff text sent to the model (default ~120k characters). | -| `OPENAI_MAX_TOKENS` or `LLM_MAX_TOKENS` | Max completion tokens (default 4000). | +|---|---| +| `LLM_PROVIDER` | Explicit provider id from the table above. | +| `LLM_MODEL` | Overrides the per-provider default model id. | +| `OPENAI_BASE_URL` / `LLM_BASE_URL` | Base URL for an OpenAI-compatible gateway; presence alone auto-selects the `openai-compatible` provider. | +| `OPENAI_DEFAULT_HEADERS` / `LLM_DEFAULT_HEADERS` | JSON object of extra headers merged onto OpenAI / OpenAI-compatible requests (e.g. RBAC tokens, raw `Authorization`). `LLM_*` overrides `OPENAI_*` key-by-key. | +| `LLM_PROVIDER_NAME` | Display name used when `openai-compatible` is active (defaults to `openai-compatible`). | +| `OPENAI_MAX_DIFF_CHARS` / `LLM_MAX_DIFF_CHARS` | Max size of unified diff text sent to the model (default ~120k characters). | +| `OPENAI_MAX_TOKENS` / `LLM_MAX_TOKENS` | Max completion tokens (default 4000). | + +### Example: native OpenAI -The client is created with the official [`openai`](https://www.npmjs.com/package/openai) SDK via `createOpenAiLikeClient()`; use a compatible endpoint and model ID for your provider. +```powershell +$env:OPENAI_API_KEY = "sk-..." +# Optional: $env:LLM_MODEL = "gpt-4o" +``` -Example using a company-managed OpenAI-compatible gateway: +### Example: Anthropic Claude + +```powershell +$env:ANTHROPIC_API_KEY = "sk-ant-..." +$env:LLM_MODEL = "claude-3-5-sonnet-latest" # optional override +``` + +### Example: company-managed OpenAI-compatible gateway ```powershell -$env:OPENAI_DEFAULT_HEADERS = '{"x-company-rbac":"your-rbac-token-here","Authorization":"sk-your-api-key-here"}' $env:OPENAI_BASE_URL = "https://llm-gateway.example.com" +$env:OPENAI_DEFAULT_HEADERS = '{"x-company-rbac":"your-rbac-token-here","Authorization":"Bearer sk-your-api-key-here"}' +# LLM_PROVIDER is auto-detected as "openai-compatible" because LLM_BASE_URL/OPENAI_BASE_URL is set. +``` + +### Example: Google Gemini + +```powershell +$env:GOOGLE_GENERATIVE_AI_API_KEY = "..." +$env:LLM_MODEL = "gemini-2.0-flash" ``` ## Usage @@ -59,9 +102,10 @@ const markdown = await summarizeGitDiff({ commitMessageExcludeRegexes: ['^\\[bot\\]'], commitMessageIncludeRegexes: ['^feat:'], // optional; OR across patterns teamName: 'Platform', - systemPrompt: undefined, // optional; overrides DEFAULT_GIT_DIFF_SYSTEM_PROMPT - model: 'gpt-4o-mini', // optional - maxDiffChars: 120_000, // optional; also see LLM_MAX_DIFF_CHARS + systemPrompt: undefined, // optional; overrides DEFAULT_GIT_DIFF_SYSTEM_PROMPT + provider: 'anthropic', // optional; overrides LLM_PROVIDER env + auto-detection + model: 'claude-3-5-sonnet-latest', // optional + maxDiffChars: 120_000, // optional; also see LLM_MAX_DIFF_CHARS }); ``` @@ -75,9 +119,27 @@ const markdown = await summarizeGitDiff({ | `commitMessageExcludeRegexes` | Drop commits whose message matches **any** of these patterns. | | `teamName` | Adds a `Team:` line to the user payload for the model. | | `systemPrompt` | Replaces the default system prompt. | -| `model` | Chat model id (default `gpt-4o-mini`). | +| `provider` | `LlmProviderId` — wins over `LLM_PROVIDER` env and auto-detection. | +| `model` | Chat model id; overrides `LLM_MODEL` and the provider default. | | `maxDiffChars` | Caps unified diff size for the request. | -| `openAiClientProvider` | `() => Promise` — bypasses env-based client creation (required in tests or when you wire the SDK yourself). | +| `llmModelProvider` | `() => Promise` — bypass env-based resolution entirely; hand-wire a Vercel AI SDK `LanguageModel` (required in tests or custom setups). | + +### Injecting your own `LanguageModel` + +If you want full control — for example, to configure retries, middlewares, or hit an in-process mock — pass `llmModelProvider`: + +```ts +import { summarizeGitDiff } from '@mcarvin/smart-diff'; +import { createAnthropic } from '@ai-sdk/anthropic'; + +const md = await summarizeGitDiff({ + from: 'origin/main', + llmModelProvider: async () => + createAnthropic({ apiKey: process.env.MY_ANTHROPIC_KEY })( + 'claude-3-5-sonnet-latest', + ), +}); +``` ### Diff shape: single range vs per-commit @@ -86,13 +148,28 @@ const markdown = await summarizeGitDiff({ ### Lower-level API -The package also exports helpers such as `createGitClient`, `getCommits`, `getDiff`, `getDiffSummary`, `getChangedFiles`, `filterCommitsByMessageRegexes`, `buildDiffPathspecs`, `generateSummary`, and OpenAI config utilities (`resolveLlmBaseUrl`, `shouldUseLlmGateway`, `createOpenAiLikeClient`, …). Use these if you build a custom pipeline but still want the same git and LLM behavior. +The package also exports helpers for building a custom pipeline on top of the same git and LLM behavior: + +- **Git**: `createGitClient`, `getRepoRoot`, `getCommits`, `getDiff`, `getDiffSummary`, `getChangedFiles`, `filterCommitsByMessageRegexes`, `buildDiffPathspecs` +- **AI**: `generateSummary`, `resolveLlmMaxDiffChars`, `truncateUnifiedDiffForLlm` +- **Provider resolution**: `resolveLanguageModel`, `detectLlmProvider`, `isLlmProviderConfigured`, `defaultModelForProvider`, `resolveLlmBaseUrl`, `parseLlmDefaultHeadersFromEnv` +- **Constants / types**: `DEFAULT_GIT_DIFF_SYSTEM_PROMPT`, `LLM_GATEWAY_REQUIRED_MESSAGE`, `LlmProviderId`, `LlmModelProvider`, `ResolveLanguageModelOptions`, `GenerateSummaryInput`, `SummarizeFlags` + +## Migrating from 1.x → 2.x + +v2 replaces the direct `openai` SDK dependency with the Vercel AI SDK. If you only rely on env-var configuration, your setup keeps working — `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_DEFAULT_HEADERS`, `LLM_*` equivalents, `OPENAI_MAX_DIFF_CHARS`, and `OPENAI_MAX_TOKENS` are all still honored. + +Breaking changes: + +- **Removed `openAiClientProvider` option** on `summarizeGitDiff`/`generateSummary`. Use `llmModelProvider: () => Promise` returning a Vercel AI SDK model instead. +- **Removed `OpenAiLikeClient` and `createOpenAiLikeClient` exports**, along with `shouldUseLlmGateway`. Use `isLlmProviderConfigured()` / `resolveLanguageModel()` instead. +- **`openai` npm package is no longer a dependency.** Remove it from your own `package.json` if you only depended on it transitively via smart-diff. ## Used By This package is used by: -- [sf-git-ai-meta-insights](https://github.com/mcarvin8/sf-git-ai-meta-insights) = Salesforce metadata wrapper compatible with Salesforce DX projects +- [sf-git-ai-meta-insights](https://github.com/mcarvin8/sf-git-ai-meta-insights) — Salesforce metadata wrapper compatible with Salesforce DX projects ## License diff --git a/package-lock.json b/package-lock.json index 2ff7fe0..dd26f01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@mcarvin/smart-diff", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mcarvin/smart-diff", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "dependencies": { + "@ai-sdk/openai": "^3.0.53", + "@ai-sdk/openai-compatible": "^2.0.41", + "ai": "^6.0.168", "fs-extra": "^11.3.4", - "openai": "^6.34.0", "simple-git": "^3.20.0", "tslib": "^2.6.2" }, @@ -32,6 +34,298 @@ }, "engines": { "node": ">=20" + }, + "optionalDependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.96", + "@ai-sdk/anthropic": "^3.0.71", + "@ai-sdk/cohere": "^3.0.30", + "@ai-sdk/deepseek": "^2.0.29", + "@ai-sdk/google": "^3.0.64", + "@ai-sdk/groq": "^3.0.35", + "@ai-sdk/mistral": "^3.0.30", + "@ai-sdk/xai": "^3.0.83" + } + }, + "node_modules/@ai-sdk/amazon-bedrock": { + "version": "4.0.96", + "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.96.tgz", + "integrity": "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA==", + "optional": true, + "dependencies": { + "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@smithy/eventstream-codec": "^4.0.1", + "@smithy/util-utf8": "^4.0.0", + "aws4fetch": "^1.0.20" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.71", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.71.tgz", + "integrity": "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg==", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/cohere": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/cohere/-/cohere-3.0.30.tgz", + "integrity": "sha512-j3fe/6lUUkHPD/51OgMXN9UD7p1QSQEAlroIinmb3MhJ1s+O0MnqdRa30IM7dRHafNp0FQ9X4YpobY85iMknUQ==", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/deepseek": { + "version": "2.0.29", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.29.tgz", + "integrity": "sha512-cn4+xV0menm/4JKEDElnVGiUilHvi6AD4ZK/sY7DXP/Wb7Yb3Vr86NyYM6mGBE/Shk3mWHoHbzggVnF5x0uMEA==", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.104", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.104.tgz", + "integrity": "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.64", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.64.tgz", + "integrity": "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA==", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.35", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.35.tgz", + "integrity": "sha512-LXoPwSKaqXst9LyLN2J7gK8n7RldQLbP2zsnBYxXcOsXKrtceksqtbsmGXujvab2TM9FisquAw/ZG2hTbD5vnQ==", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.30.tgz", + "integrity": "sha512-+j4IXRSk9E661cFSafmIr+XHOzwjFagawwzMOlSqwL6U4Sq4PCFLDF+oHbX5NUqNjUL7FD1zi/9lBIfa41pUvw==", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.53.tgz", + "integrity": "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ==", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.41", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.41.tgz", + "integrity": "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g==", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", + "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/xai": { + "version": "3.0.83", + "resolved": "https://registry.npmjs.org/@ai-sdk/xai/-/xai-3.0.83.tgz", + "integrity": "sha512-SuQz68BZGeuZjrSUJAzku97IlhdiNJJBsvG/Tvm3K2tuxkBS7TJq0fH4/AzAM7w2H2jxVUgboP7kRR6IfpRxcg==", + "optional": true, + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.41", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "optional": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "optional": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "optional": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "optional": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "optional": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "optional": true, + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@babel/code-frame": { @@ -1169,6 +1463,14 @@ "@tybys/wasm-util": "^0.10.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==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1623,6 +1925,88 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "optional": true, + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "optional": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "optional": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "optional": true, + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "optional": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "optional": true, + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2031,6 +2415,14 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "engines": { + "node": ">= 20" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2052,6 +2444,23 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ai": { + "version": "6.0.168", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.168.tgz", + "integrity": "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==", + "dependencies": { + "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2144,6 +2553,12 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "optional": true + }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -2934,6 +3349,14 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4150,6 +4573,11 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4637,26 +5065,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openai": { - "version": "6.34.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz", - "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6098,6 +6506,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 1009dfa..ba8391f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mcarvin/smart-diff", - "version": "1.1.0", - "description": "Summarizes a git diff using OpenAI models.", + "version": "2.0.0", + "description": "Summarizes a git diff using any LLM provider supported by the Vercel AI SDK (OpenAI, Anthropic, Google, Bedrock, Mistral, Cohere, Groq, xAI, DeepSeek, or any OpenAI-compatible gateway).", "author": "Matt Carvin", "license": "MIT", "main": "dist/index.cjs", @@ -45,10 +45,22 @@ "typescript": "^5.9.3" }, "dependencies": { + "@ai-sdk/openai": "^3.0.53", + "@ai-sdk/openai-compatible": "^2.0.41", + "ai": "^6.0.168", "fs-extra": "^11.3.4", - "tslib": "^2.6.2", - "openai": "^6.34.0", - "simple-git": "^3.20.0" + "simple-git": "^3.20.0", + "tslib": "^2.6.2" + }, + "optionalDependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.96", + "@ai-sdk/anthropic": "^3.0.71", + "@ai-sdk/cohere": "^3.0.30", + "@ai-sdk/deepseek": "^2.0.29", + "@ai-sdk/google": "^3.0.64", + "@ai-sdk/groq": "^3.0.35", + "@ai-sdk/mistral": "^3.0.30", + "@ai-sdk/xai": "^3.0.83" }, "repository": { "type": "git", @@ -58,8 +70,22 @@ "url": "https://github.com/mcarvin8/smart-diff/issues" }, "keywords": [ - "openai", + "ai", + "ai-sdk", + "anthropic", + "bedrock", + "cohere", + "deepseek", + "diff", + "gemini", "git", - "diff" + "google", + "groq", + "llm", + "mistral", + "openai", + "openai-compatible", + "vercel-ai-sdk", + "xai" ] } diff --git a/src/ai/aiConstants.ts b/src/ai/aiConstants.ts index e921c45..e6e06fa 100644 --- a/src/ai/aiConstants.ts +++ b/src/ai/aiConstants.ts @@ -13,8 +13,10 @@ Use sections that fit the change (for example: Highlights, Breaking or risky cha Group related changes; do not list every individual file. When multiple commits appear in the context, briefly separate notable themes by commit when helpful. If the user message includes a Team line, use that exact team name in the summary title (for example: "## – Change summary" or similar).`; -/** Thrown when no LLM gateway is configured and no `openAiClientProvider` was passed. */ +/** Thrown when no LLM provider is configured and no injection point was passed. */ export const LLM_GATEWAY_REQUIRED_MESSAGE = - "No LLM gateway configured. Set OPENAI_API_KEY or LLM_API_KEY, and/or LLM_BASE_URL or OPENAI_BASE_URL, " + - "and/or JSON in OPENAI_DEFAULT_HEADERS or LLM_DEFAULT_HEADERS. " + - "Alternatively pass openAiClientProvider to generateSummary or summarizeGitDiff."; + "No LLM provider configured. Set LLM_PROVIDER (openai | openai-compatible | anthropic | google | bedrock | mistral | cohere | groq | xai | deepseek), " + + "or a provider API key (OPENAI_API_KEY, LLM_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, MISTRAL_API_KEY, COHERE_API_KEY, GROQ_API_KEY, XAI_API_KEY, DEEPSEEK_API_KEY), " + + "or LLM_BASE_URL / OPENAI_BASE_URL for an OpenAI-compatible gateway, " + + "or JSON in OPENAI_DEFAULT_HEADERS / LLM_DEFAULT_HEADERS. " + + "Alternatively pass llmModelProvider or openAiClientProvider to generateSummary or summarizeGitDiff."; diff --git a/src/ai/aiSummary.ts b/src/ai/aiSummary.ts index e365dd6..d7215c3 100644 --- a/src/ai/aiSummary.ts +++ b/src/ai/aiSummary.ts @@ -1,9 +1,6 @@ +import { generateText } from "ai"; + import type { CommitInfo, DiffSummary } from "../git/gitDiff.js"; -import { - createOpenAiLikeClient, - shouldUseLlmGateway, - type OpenAiLikeClient, -} from "./openAIConfig.js"; import { DEFAULT_LLM_MAX_DIFF_CHARS, DEFAULT_GIT_DIFF_SYSTEM_PROMPT, @@ -11,11 +8,15 @@ import { } from "./aiConstants.js"; import type { GenerateSummaryInput, - OpenAiClientProvider, + LlmModelProvider, SummarizeFlags, } from "./aiTypes.js"; +import { + isLlmProviderConfigured, + resolveLanguageModel, +} from "./llmProviders.js"; -/** Resolve max unified-diff characters for the LLM path. CLI wins, then env, then default. */ +/** Resolve max unified-diff characters sent to the LLM. CLI wins, then env, then default. */ export function resolveLlmMaxDiffChars(cliOverride?: number): number { if ( cliOverride !== undefined && @@ -52,6 +53,12 @@ function markdownDiffTruncationNotice( return `> **Truncated diff:** The unified diff was ${originalChars} characters; only the first ${maxChars} were sent to the model. The summary may not reflect the full change set. Narrow the ref range, adjust path filters, or raise \`maxDiffChars\` / \`LLM_MAX_DIFF_CHARS\`—often together with switching to a model whose context window can fit a larger prompt.\n\n`; } +function resolveMaxOutputTokens(): number { + const raw = process.env.LLM_MAX_TOKENS ?? process.env.OPENAI_MAX_TOKENS; + const parsed = raw !== undefined ? Number.parseInt(raw, 10) : 4000; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 4000; +} + export async function generateSummary( input: GenerateSummaryInput, ): Promise { @@ -60,32 +67,35 @@ export async function generateSummary( fileNames, commits, flags, - openAiClientProvider, + llmModelProvider, diffSummary, } = input; - if (!shouldUseLlmGateway() && openAiClientProvider === undefined) { + if (!llmModelProvider && !isLlmProviderConfigured()) { throw new Error(LLM_GATEWAY_REQUIRED_MESSAGE); } const maxDiffChars = resolveLlmMaxDiffChars(flags.maxDiffChars); const diffTruncated = diffText.length > maxDiffChars; const diffForLlm = truncateUnifiedDiffForLlm(diffText, maxDiffChars); - const userContent = buildOpenAiUserContent( + const userContent = buildUserContent( flags, commits, fileNames, diffForLlm, diffSummary, ); - const summary = await callOpenAi( + const systemPrompt = flags.systemPrompt ?? DEFAULT_GIT_DIFF_SYSTEM_PROMPT; + const maxOutputTokens = resolveMaxOutputTokens(); + + const summary = await callLlm( userContent, - flags.model ?? "gpt-4o-mini", - flags.systemPrompt ?? DEFAULT_GIT_DIFF_SYSTEM_PROMPT, - openAiClientProvider ?? - /* istanbul ignore next */ (async (): Promise => - createOpenAiLikeClient()), + systemPrompt, + maxOutputTokens, + llmModelProvider, + flags, ); + if (!diffTruncated) { return summary; } @@ -119,7 +129,7 @@ function formatRegexFilterLines(flags: SummarizeFlags): string { ); } -function buildOpenAiUserContent( +function buildUserContent( flags: SummarizeFlags, commits: CommitInfo[], fileNames: string[], @@ -165,41 +175,28 @@ function buildOpenAiUserContent( ); } -async function callOpenAi( +async function callLlm( userContent: string, - model: string, systemPrompt: string, - openAiClientProvider: OpenAiClientProvider, + maxOutputTokens: number, + llmModelProvider: LlmModelProvider | undefined, + flags: SummarizeFlags, ): Promise { - const client = await openAiClientProvider(); - const maxTokensRaw = - process.env.LLM_MAX_TOKENS ?? process.env.OPENAI_MAX_TOKENS; - const parsed = - maxTokensRaw !== undefined ? Number.parseInt(maxTokensRaw, 10) : 4000; - const maxTokens = Number.isFinite(parsed) && parsed > 0 ? parsed : 4000; - - const response = await client.chat.completions.create({ + const model = llmModelProvider + ? await llmModelProvider() + : await resolveLanguageModel({ + ...(flags.provider ? { provider: flags.provider } : {}), + ...(flags.model ? { model: flags.model } : {}), + }); + + const result = await generateText({ model, - messages: [ - { - role: "system", - content: systemPrompt, - }, - { - role: "user", - content: userContent, - }, - ], + system: systemPrompt, + prompt: userContent, temperature: 0.2, - // OpenAI Chat Completions API uses snake_case for this field. - // eslint-disable-next-line camelcase -- matches OpenAI request body - max_tokens: maxTokens, + maxOutputTokens, }); - const typedResponse = response as { - choices?: Array<{ message?: { content?: string } }>; - }; - - const text = typedResponse.choices?.[0]?.message?.content?.trim() ?? ""; - return text.length > 0 ? text : "No summary generated by OpenAI."; + const text = result.text?.trim() ?? ""; + return text.length > 0 ? text : "No summary generated by the model."; } diff --git a/src/ai/aiTypes.ts b/src/ai/aiTypes.ts index 0c804ce..fa38a6d 100644 --- a/src/ai/aiTypes.ts +++ b/src/ai/aiTypes.ts @@ -1,11 +1,16 @@ +import type { LanguageModel } from "ai"; + import type { CommitInfo, DiffSummary } from "../git/gitDiff.js"; -import { type OpenAiLikeClient } from "./openAIConfig.js"; +import type { LlmProviderId } from "./llmProviders.js"; export type SummarizeFlags = { /** Start ref for the diff. */ from: string; to?: string; + /** Model id for the resolved provider (overrides `LLM_MODEL` env and provider default). */ model?: string; + /** Provider id override (wins over `LLM_PROVIDER` env and auto-detection). */ + provider?: LlmProviderId; /** Optional team or squad label for the summary title and context. */ team?: string; /** Max characters of unified diff sent to the LLM; see `resolveLlmMaxDiffChars`. */ @@ -16,7 +21,11 @@ export type SummarizeFlags = { commitMessageExcludeRegexes?: string[]; }; -export type OpenAiClientProvider = () => Promise; +/** + * Factory returning a Vercel AI SDK `LanguageModel`. Use this for tests or when you + * want to hand-wire a provider instead of relying on env-based resolution. + */ +export type LlmModelProvider = () => Promise; /** Input object for `generateSummary` (see `aiSummary.ts`). */ export type GenerateSummaryInput = { @@ -24,6 +33,7 @@ export type GenerateSummaryInput = { fileNames: string[]; commits: CommitInfo[]; flags: SummarizeFlags; - openAiClientProvider?: OpenAiClientProvider; + /** Returns a Vercel AI SDK `LanguageModel` — bypasses env-based provider resolution. */ + llmModelProvider?: LlmModelProvider; diffSummary?: DiffSummary; }; diff --git a/src/ai/llmProviders.ts b/src/ai/llmProviders.ts new file mode 100644 index 0000000..de474a2 --- /dev/null +++ b/src/ai/llmProviders.ts @@ -0,0 +1,402 @@ +/** + * Resolves a Vercel AI SDK `LanguageModel` for the configured provider, using + * lazy dynamic imports so optional provider SDKs are only loaded when requested. + * + * Providers: + * - `openai` — `@ai-sdk/openai` (default when only OpenAI creds are set) + * - `openai-compatible` — `@ai-sdk/openai-compatible` (default when `LLM_BASE_URL`/`OPENAI_BASE_URL` is set; works with Groq, Together, Fireworks, Azure OpenAI, DeepSeek, xAI, OpenRouter, Ollama, vLLM, LocalAI, Perplexity, corporate gateways, etc.) + * - `anthropic` — `@ai-sdk/anthropic` + * - `google` — `@ai-sdk/google` (Gemini) + * - `bedrock` — `@ai-sdk/amazon-bedrock` + * - `mistral` — `@ai-sdk/mistral` (native API) + * - `cohere` — `@ai-sdk/cohere` + * - `groq` — `@ai-sdk/groq` + * - `xai` — `@ai-sdk/xai` + * - `deepseek` — `@ai-sdk/deepseek` + * + * `LLM_PROVIDER` selects explicitly; otherwise the resolver auto-detects based on the set env vars. + */ + +import type { LanguageModel } from "ai"; + +export type LlmProviderId = + | "openai" + | "openai-compatible" + | "anthropic" + | "google" + | "bedrock" + | "mistral" + | "cohere" + | "groq" + | "xai" + | "deepseek"; + +const DEFAULT_MODEL_BY_PROVIDER: Record = { + openai: "gpt-4o-mini", + "openai-compatible": "gpt-4o-mini", + anthropic: "claude-3-5-haiku-latest", + google: "gemini-2.0-flash", + bedrock: "anthropic.claude-3-5-haiku-20241022-v1:0", + mistral: "mistral-small-latest", + cohere: "command-r-08-2024", + groq: "llama-3.1-8b-instant", + xai: "grok-2-latest", + deepseek: "deepseek-chat", +}; + +const VALID_PROVIDERS: ReadonlySet = new Set([ + "openai", + "openai-compatible", + "anthropic", + "google", + "bedrock", + "mistral", + "cohere", + "groq", + "xai", + "deepseek", +]); + +function readEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value && value.length > 0 ? value : undefined; +} + +function isValidProviderId(value: string): value is LlmProviderId { + return VALID_PROVIDERS.has(value as LlmProviderId); +} + +/** `LLM_BASE_URL` wins over `OPENAI_BASE_URL` when set. */ +export function resolveLlmBaseUrl(): string | undefined { + return readEnv("LLM_BASE_URL") ?? readEnv("OPENAI_BASE_URL"); +} + +function parseHeaderJsonObject( + raw: string | undefined, +): Record { + const trimmed = raw?.trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed) as unknown; + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return {}; + } + const out: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string" && value.length > 0) { + out[key] = value; + } + } + return out; + } catch { + return {}; + } +} + +/** + * Merged default headers for OpenAI / OpenAI-compatible gateways: + * `OPENAI_DEFAULT_HEADERS` first, then `LLM_DEFAULT_HEADERS` overrides. + */ +export function parseLlmDefaultHeadersFromEnv(): + | Record + | undefined { + const base = parseHeaderJsonObject(process.env.OPENAI_DEFAULT_HEADERS); + const override = parseHeaderJsonObject(process.env.LLM_DEFAULT_HEADERS); + const merged = { ...base, ...override }; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function resolveOpenAiApiKey(): string | undefined { + return readEnv("LLM_API_KEY") ?? readEnv("OPENAI_API_KEY"); +} + +/** + * Returns the explicit `LLM_PROVIDER` if set and valid, otherwise auto-detects + * from the set env vars. Returns `undefined` when nothing is configured. + */ +export function detectLlmProvider(): LlmProviderId | undefined { + const explicit = readEnv("LLM_PROVIDER")?.toLowerCase(); + if (explicit && isValidProviderId(explicit)) { + return explicit; + } + if (resolveLlmBaseUrl()) { + return "openai-compatible"; + } + if (resolveOpenAiApiKey()) { + return "openai"; + } + if (readEnv("ANTHROPIC_API_KEY")) return "anthropic"; + if (readEnv("GOOGLE_GENERATIVE_AI_API_KEY") ?? readEnv("GOOGLE_API_KEY")) + return "google"; + if (readEnv("MISTRAL_API_KEY")) return "mistral"; + if (readEnv("COHERE_API_KEY")) return "cohere"; + if (readEnv("GROQ_API_KEY")) return "groq"; + if (readEnv("XAI_API_KEY")) return "xai"; + if (readEnv("DEEPSEEK_API_KEY")) return "deepseek"; + if (parseLlmDefaultHeadersFromEnv()) return "openai"; + return undefined; +} + +/** True when any supported provider can be resolved from env vars. */ +export function isLlmProviderConfigured(): boolean { + return detectLlmProvider() !== undefined; +} + +/** Default chat model id for the given provider. */ +export function defaultModelForProvider(provider: LlmProviderId): string { + return DEFAULT_MODEL_BY_PROVIDER[provider]; +} + +async function createOpenAiModel(modelId: string): Promise { + const { createOpenAI } = await import("@ai-sdk/openai"); + const apiKey = resolveOpenAiApiKey(); + const headers = parseLlmDefaultHeadersFromEnv(); + const provider = createOpenAI({ + ...(apiKey ? { apiKey } : {}), + ...(headers ? { headers } : {}), + }); + return provider(modelId); +} + +async function createOpenAiCompatibleModel( + modelId: string, +): Promise { + const { createOpenAICompatible } = await import("@ai-sdk/openai-compatible"); + const baseURL = resolveLlmBaseUrl(); + if (!baseURL) { + throw new Error( + "openai-compatible provider requires LLM_BASE_URL or OPENAI_BASE_URL to be set.", + ); + } + const apiKey = resolveOpenAiApiKey(); + const headers = parseLlmDefaultHeadersFromEnv(); + const provider = createOpenAICompatible({ + name: readEnv("LLM_PROVIDER_NAME") ?? "openai-compatible", + baseURL, + ...(apiKey ? { apiKey } : {}), + ...(headers ? { headers } : {}), + }); + return provider(modelId); +} + +type ImportFailure = { + provider: LlmProviderId; + pkg: string; + cause: unknown; +}; + +function wrapMissingPeer(failure: ImportFailure): Error { + const err = new Error( + `Failed to load optional provider package "${failure.pkg}" for LLM_PROVIDER="${failure.provider}". ` + + `Install it with \`npm install ${failure.pkg}\`.`, + ); + (err as Error & { cause?: unknown }).cause = failure.cause; + return err; +} + +async function importOptional( + provider: LlmProviderId, + pkg: string, + loader: () => Promise, +): Promise { + try { + return await loader(); + } catch (cause) { + throw wrapMissingPeer({ provider, pkg, cause }); + } +} + +async function createAnthropicModel(modelId: string): Promise { + const mod = await importOptional( + "anthropic", + "@ai-sdk/anthropic", + () => + import("@ai-sdk/anthropic") as unknown as Promise<{ + createAnthropic: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = readEnv("ANTHROPIC_API_KEY"); + const provider = mod.createAnthropic(apiKey ? { apiKey } : undefined); + return provider(modelId); +} + +async function createGoogleModel(modelId: string): Promise { + const mod = await importOptional( + "google", + "@ai-sdk/google", + () => + import("@ai-sdk/google") as unknown as Promise<{ + createGoogleGenerativeAI: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = + readEnv("GOOGLE_GENERATIVE_AI_API_KEY") ?? readEnv("GOOGLE_API_KEY"); + const provider = mod.createGoogleGenerativeAI( + apiKey ? { apiKey } : undefined, + ); + return provider(modelId); +} + +async function createBedrockModel(modelId: string): Promise { + const mod = await importOptional( + "bedrock", + "@ai-sdk/amazon-bedrock", + () => + import("@ai-sdk/amazon-bedrock") as unknown as Promise<{ + createAmazonBedrock: (options?: Record) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const provider = mod.createAmazonBedrock(); + return provider(modelId); +} + +async function createMistralModel(modelId: string): Promise { + const mod = await importOptional( + "mistral", + "@ai-sdk/mistral", + () => + import("@ai-sdk/mistral") as unknown as Promise<{ + createMistral: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = readEnv("MISTRAL_API_KEY"); + const provider = mod.createMistral(apiKey ? { apiKey } : undefined); + return provider(modelId); +} + +async function createCohereModel(modelId: string): Promise { + const mod = await importOptional( + "cohere", + "@ai-sdk/cohere", + () => + import("@ai-sdk/cohere") as unknown as Promise<{ + createCohere: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = readEnv("COHERE_API_KEY"); + const provider = mod.createCohere(apiKey ? { apiKey } : undefined); + return provider(modelId); +} + +async function createGroqModel(modelId: string): Promise { + const mod = await importOptional( + "groq", + "@ai-sdk/groq", + () => + import("@ai-sdk/groq") as unknown as Promise<{ + createGroq: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = readEnv("GROQ_API_KEY"); + const provider = mod.createGroq(apiKey ? { apiKey } : undefined); + return provider(modelId); +} + +async function createXaiModel(modelId: string): Promise { + const mod = await importOptional( + "xai", + "@ai-sdk/xai", + () => + import("@ai-sdk/xai") as unknown as Promise<{ + createXai: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = readEnv("XAI_API_KEY"); + const provider = mod.createXai(apiKey ? { apiKey } : undefined); + return provider(modelId); +} + +async function createDeepseekModel(modelId: string): Promise { + const mod = await importOptional( + "deepseek", + "@ai-sdk/deepseek", + () => + import("@ai-sdk/deepseek") as unknown as Promise<{ + createDeepSeek: (options?: { apiKey?: string }) => ( + modelId: string, + ) => LanguageModel; + }>, + ); + const apiKey = readEnv("DEEPSEEK_API_KEY"); + const provider = mod.createDeepSeek(apiKey ? { apiKey } : undefined); + return provider(modelId); +} + +export type ResolveLanguageModelOptions = { + provider?: LlmProviderId; + model?: string; +}; + +/** + * Resolve a Vercel AI SDK `LanguageModel` for the requested provider and model. + * + * Resolution order for the provider: + * 1. `options.provider` + * 2. `LLM_PROVIDER` env var + * 3. auto-detect from env vars ({@link detectLlmProvider}) + * + * Resolution order for the model id: + * 1. `options.model` + * 2. `LLM_MODEL` env var + * 3. provider default ({@link defaultModelForProvider}) + */ +export async function resolveLanguageModel( + options: ResolveLanguageModelOptions = {}, +): Promise { + const provider = options.provider ?? detectLlmProvider(); + if (!provider) { + throw new Error( + "No LLM provider could be resolved. Set LLM_PROVIDER or a provider API key " + + "(OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, MISTRAL_API_KEY, " + + "COHERE_API_KEY, GROQ_API_KEY, XAI_API_KEY, DEEPSEEK_API_KEY), or LLM_BASE_URL for an OpenAI-compatible gateway.", + ); + } + const modelId = + options.model ?? readEnv("LLM_MODEL") ?? defaultModelForProvider(provider); + + switch (provider) { + case "openai": + return createOpenAiModel(modelId); + case "openai-compatible": + return createOpenAiCompatibleModel(modelId); + case "anthropic": + return createAnthropicModel(modelId); + case "google": + return createGoogleModel(modelId); + case "bedrock": + return createBedrockModel(modelId); + case "mistral": + return createMistralModel(modelId); + case "cohere": + return createCohereModel(modelId); + case "groq": + return createGroqModel(modelId); + case "xai": + return createXaiModel(modelId); + case "deepseek": + return createDeepseekModel(modelId); + /* istanbul ignore next -- exhaustive switch */ + default: { + const _exhaustive: never = provider; + throw new Error(`Unhandled LLM provider: ${String(_exhaustive)}`); + } + } +} diff --git a/src/ai/openAIConfig.ts b/src/ai/openAIConfig.ts deleted file mode 100644 index 0684d15..0000000 --- a/src/ai/openAIConfig.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * OpenAI-compatible LLM gateways (OpenAI.com, Azure OpenAI, corporate proxies, etc.). - * `LLM_*` environment variables override matching `OPENAI_*` defaults where applicable. - */ - -/** `LLM_BASE_URL` overrides `OPENAI_BASE_URL` when set. */ -export function resolveLlmBaseUrl(): string | undefined { - return ( - process.env.LLM_BASE_URL?.trim() ?? process.env.OPENAI_BASE_URL?.trim() - ); -} - -function parseHeaderJsonObject( - raw: string | undefined, -): Record { - const trimmed = raw?.trim(); - if (!trimmed) return {}; - try { - const parsed = JSON.parse(trimmed) as unknown; - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) - ) { - return {}; - } - const out: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "string" && value.length > 0) { - out[key] = value; - } - } - return out; - } catch { - return {}; - } -} - -/** - * Merged default headers: `OPENAI_DEFAULT_HEADERS` first, then `LLM_DEFAULT_HEADERS` overrides. - */ -export function parseLlmDefaultHeadersFromEnv(): - | Record - | undefined { - const base = parseHeaderJsonObject(process.env.OPENAI_DEFAULT_HEADERS); - const override = parseHeaderJsonObject(process.env.LLM_DEFAULT_HEADERS); - const merged = { ...base, ...override }; - return Object.keys(merged).length > 0 ? merged : undefined; -} - -function findAuthorizationHeaderName( - headers: Record, -): string | undefined { - return Object.keys(headers).find((k) => k.toLowerCase() === "authorization"); -} - -/** Strip a single `Bearer ` prefix; otherwise return the trimmed value. */ -function stripBearerPrefix(value: string): string { - const trimmed = value.trim(); - const match = /^Bearer\s+(\S+)/i.exec(trimmed); - return match?.[1] ?? trimmed; -} - -/** - * OpenAI's Node SDK always sends `Authorization: Bearer ${apiKey}`. - * If `Authorization` is only present in `defaultHeaders` as a raw `sk-...` token (no Bearer), - * that header overrides the SDK value and many OpenAI-compatible gateways reject the request - * (`param: api_key`). When no `LLM_API_KEY` / `OPENAI_API_KEY` is set, promote recognizable - * tokens from `Authorization` into `apiKey` and drop that header from `defaultHeaders`. - */ -export function splitPromotableAuthorizationFromHeaders( - headers: Record, -): { - defaultHeaders: Record; - apiKeyFromAuthHeader?: string; -} { - const authName = findAuthorizationHeaderName(headers); - if (!authName) { - return { defaultHeaders: headers }; - } - const raw = headers[authName]; - if (!raw) { - return { defaultHeaders: headers }; - } - const token = stripBearerPrefix(raw); - const looksBearer = /^Bearer\s+\S+/i.test(raw.trim()); - const looksOpenAiKey = /^sk-/i.test(token); - if (!looksBearer && !looksOpenAiKey) { - return { defaultHeaders: headers }; - } - const next: Record = { ...headers }; - delete next[authName]; - return { defaultHeaders: next, apiKeyFromAuthHeader: token }; -} - -export function shouldUseLlmGateway(): boolean { - const apiKey = - process.env.LLM_API_KEY?.trim() ?? process.env.OPENAI_API_KEY?.trim(); - if (apiKey) return true; - if (resolveLlmBaseUrl()) return true; - const jsonHeaders = parseLlmDefaultHeadersFromEnv(); - if (jsonHeaders && Object.keys(jsonHeaders).length > 0) return true; - return false; -} - -export type OpenAiLikeClient = { - chat: { - completions: { - create(...options: unknown[]): Promise; - }; - }; -}; - -/** Constructor-style options for the official OpenAI Node SDK (for tests and `createOpenAiLikeClient`). */ -export type OpenAiLikeClientInit = { - apiKey: string; - baseURL?: string; - defaultHeaders?: Record; -}; - -export function resolveOpenAiLikeClientInit(): OpenAiLikeClientInit { - const baseURL = resolveLlmBaseUrl(); - const mergedHeaders = parseLlmDefaultHeadersFromEnv() ?? {}; - const envApiKey = - process.env.LLM_API_KEY?.trim() ?? process.env.OPENAI_API_KEY?.trim() ?? ""; - - let defaultHeaders: Record | undefined; - let apiKey = envApiKey; - - if (apiKey.length === 0) { - const split = splitPromotableAuthorizationFromHeaders(mergedHeaders); - if (split.apiKeyFromAuthHeader) { - apiKey = split.apiKeyFromAuthHeader; - } - defaultHeaders = - Object.keys(split.defaultHeaders).length > 0 - ? split.defaultHeaders - : undefined; - } else { - defaultHeaders = - Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined; - } - - return { - apiKey: apiKey.length > 0 ? apiKey : "unused", - ...(baseURL ? { baseURL } : {}), - ...(defaultHeaders ? { defaultHeaders } : {}), - }; -} - -/** Build options for `new OpenAI(...)` (official OpenAI Node SDK). */ -export async function createOpenAiLikeClient(): Promise { - const { default: OpenAI } = await import("openai"); - return new OpenAI(resolveOpenAiLikeClientInit()) as OpenAiLikeClient; -} diff --git a/src/index.ts b/src/index.ts index 182c390..a0aa423 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import type { SimpleGit } from "simple-git"; import { generateSummary } from "./ai/aiSummary.js"; -import type { SummarizeFlags } from "./ai/aiTypes.js"; -import type { OpenAiLikeClient } from "./ai/openAIConfig.js"; +import type { LlmModelProvider, SummarizeFlags } from "./ai/aiTypes.js"; +import type { LlmProviderId } from "./ai/llmProviders.js"; import { createGitClient, filterCommitsByMessageRegexes, @@ -45,9 +45,17 @@ export type GitDiffAiSummaryOptions = { /** Shown in the LLM user prompt (Team line) when set. */ teamName?: string; model?: string; + /** + * Explicit LLM provider id. When omitted, falls back to `LLM_PROVIDER` env var + * or auto-detection based on which provider credentials are present. + */ + provider?: LlmProviderId; maxDiffChars?: number; - /** Optional OpenAI-compatible client factory (for tests or custom SDK wiring). */ - openAiClientProvider?: () => Promise; + /** + * Optional factory returning a Vercel AI SDK `LanguageModel` — bypass env-based + * provider resolution (useful in tests and bespoke setups). + */ + llmModelProvider?: LlmModelProvider; }; function hasNonEmptyTrimmed(arr?: string[]): boolean { @@ -122,6 +130,7 @@ export async function summarizeGitDiff( to, team: options.teamName, model: options.model, + provider: options.provider, maxDiffChars: options.maxDiffChars, systemPrompt: options.systemPrompt, commitMessageIncludeRegexes: options.commitMessageIncludeRegexes, @@ -133,7 +142,7 @@ export async function summarizeGitDiff( fileNames, commits: filteredCommits, flags: summarizeFlags, - openAiClientProvider: options.openAiClientProvider, + llmModelProvider: options.llmModelProvider, diffSummary, }); } @@ -156,27 +165,31 @@ export { getRepoRoot, } from "./git/gitDiff.js"; -export type { GenerateSummaryInput, SummarizeFlags } from "./ai/aiTypes.js"; +export type { + GenerateSummaryInput, + LlmModelProvider, + SummarizeFlags, +} from "./ai/aiTypes.js"; export { generateSummary, resolveLlmMaxDiffChars, truncateUnifiedDiffForLlm, } from "./ai/aiSummary.js"; -export { - DEFAULT_GIT_DIFF_SYSTEM_PROMPT, - LLM_GATEWAY_REQUIRED_MESSAGE, -} from "./ai/aiConstants.js"; - export type { - OpenAiLikeClient, - OpenAiLikeClientInit, -} from "./ai/openAIConfig.js"; + LlmProviderId, + ResolveLanguageModelOptions, +} from "./ai/llmProviders.js"; export { - createOpenAiLikeClient, + defaultModelForProvider, + detectLlmProvider, + isLlmProviderConfigured, parseLlmDefaultHeadersFromEnv, + resolveLanguageModel, resolveLlmBaseUrl, - resolveOpenAiLikeClientInit, - shouldUseLlmGateway, - splitPromotableAuthorizationFromHeaders, -} from "./ai/openAIConfig.js"; +} from "./ai/llmProviders.js"; + +export { + DEFAULT_GIT_DIFF_SYSTEM_PROMPT, + LLM_GATEWAY_REQUIRED_MESSAGE, +} from "./ai/aiConstants.js"; diff --git a/test/aiSummary.spec.ts b/test/aiSummary.spec.ts index 0017a09..8122410 100644 --- a/test/aiSummary.spec.ts +++ b/test/aiSummary.spec.ts @@ -1,5 +1,4 @@ import type { CommitInfo } from "../src/git/gitDiff"; -import * as openAIConfig from "../src/ai/openAIConfig"; import { generateSummary, resolveLlmMaxDiffChars, @@ -9,21 +8,13 @@ import { DEFAULT_GIT_DIFF_SYSTEM_PROMPT, LLM_GATEWAY_REQUIRED_MESSAGE, } from "../src/ai/aiConstants"; - -function mockLlmClient( - content: string, -): () => Promise { - return async () => - ({ - chat: { - completions: { - create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: content } }], - }), - }, - }, - }) as import("../src/ai/openAIConfig").OpenAiLikeClient; -} +import * as llmProviders from "../src/ai/llmProviders"; +import { + extractSystemText, + extractUserText, + makeMockModel as mockModel, + makeMockProvider as provideMock, +} from "./helpers/mockLlm"; describe("resolveLlmMaxDiffChars", () => { const original = process.env.LLM_MAX_DIFF_CHARS; @@ -81,8 +72,8 @@ describe("DEFAULT_GIT_DIFF_SYSTEM_PROMPT", () => { describe("LLM_GATEWAY_REQUIRED_MESSAGE", () => { it("is a stable exported string for callers", () => { - expect(LLM_GATEWAY_REQUIRED_MESSAGE).toContain("OPENAI_API_KEY"); - expect(LLM_GATEWAY_REQUIRED_MESSAGE).toContain("openAiClientProvider"); + expect(LLM_GATEWAY_REQUIRED_MESSAGE).toContain("LLM_PROVIDER"); + expect(LLM_GATEWAY_REQUIRED_MESSAGE).toContain("llmModelProvider"); }); }); @@ -96,8 +87,10 @@ describe("generateSummary", () => { jest.restoreAllMocks(); }); - it("throws when LLM gateway is off and no client provider", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(false); + it("throws when no provider is configured and no injection", async () => { + jest + .spyOn(llmProviders, "isLlmProviderConfigured") + .mockReturnValue(false); await expect( generateSummary({ @@ -109,8 +102,14 @@ describe("generateSummary", () => { ).rejects.toThrow(LLM_GATEWAY_REQUIRED_MESSAGE); }); - it("uses openAiClientProvider when gateway env is off", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(false); + it("uses llmModelProvider when passed", async () => { + jest + .spyOn(llmProviders, "isLlmProviderConfigured") + .mockReturnValue(false); + + const { llmModelProvider, calls } = provideMock( + " **Summary** from inject ", + ); const md = await generateSummary({ diffText: "diff...", @@ -120,27 +119,26 @@ describe("generateSummary", () => { ...flagsBase, team: "QA", systemPrompt: "You are a test bot.", - model: "gpt-test", + model: "ignored-when-provider-injected", maxDiffChars: 1000, }, - openAiClientProvider: mockLlmClient(" **Summary** from inject "), + llmModelProvider, }); expect(md).toBe("**Summary** from inject"); + const call = calls()[0]!; + expect(extractSystemText(call)).toBe("You are a test bot."); + expect(extractUserText(call)).toContain("Team: QA"); }); - it("calls OpenAI-compatible client when gateway is on", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - const completionCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: " **Summary** from model " } }], - }); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { - completions: { - create: completionCreate, - }, - }, - } as Awaited>); + it("resolves from env when no injection is passed", async () => { + jest + .spyOn(llmProviders, "isLlmProviderConfigured") + .mockReturnValue(true); + const { model, calls } = mockModel(" **Summary** from env "); + jest + .spyOn(llmProviders, "resolveLanguageModel") + .mockResolvedValue(model); const md = await generateSummary({ diffText: "diff...", @@ -149,50 +147,32 @@ describe("generateSummary", () => { flags: { ...flagsBase, team: "QA", - systemPrompt: "You are a test bot.", + systemPrompt: "Test prompt.", model: "gpt-test", + provider: "openai", maxDiffChars: 1000, }, }); - expect(md).toBe("**Summary** from model"); - expect(completionCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "gpt-test", - messages: expect.arrayContaining([ - expect.objectContaining({ - role: "system", - content: "You are a test bot.", - }), - expect.objectContaining({ - role: "user", - content: expect.stringContaining("Team: QA"), - }), - ]), - }), - ); + expect(md).toBe("**Summary** from env"); + expect(llmProviders.resolveLanguageModel).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-test", + }); + const call = calls()[0]!; + expect(extractSystemText(call)).toBe("Test prompt."); + expect(extractUserText(call)).toContain("Team: QA"); }); it("prepends markdown truncation notice when diff exceeds maxDiffChars", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { - completions: { - create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: "Body only." } }], - }), - }, - }, - } as Awaited>); + const { llmModelProvider } = provideMock("Body only."); const md = await generateSummary({ diffText: "x".repeat(50), fileNames: ["f.ts"], commits, - flags: { - ...flagsBase, - maxDiffChars: 20, - }, + flags: { ...flagsBase, maxDiffChars: 20 }, + llmModelProvider, }); expect(md.startsWith("> **Truncated diff:**")).toBe(true); @@ -202,14 +182,14 @@ describe("generateSummary", () => { expect(md).toContain("Body only."); }); - it("defaults model to gpt-4o-mini when omitted", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - const completionCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: "ok" } }], - }); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { completions: { create: completionCreate } }, - } as Awaited>); + it("passes model/provider options through to resolveLanguageModel with defaults", async () => { + jest + .spyOn(llmProviders, "isLlmProviderConfigured") + .mockReturnValue(true); + const { model } = mockModel("ok"); + const spy = jest + .spyOn(llmProviders, "resolveLanguageModel") + .mockResolvedValue(model); await generateSummary({ diffText: "d", @@ -217,19 +197,12 @@ describe("generateSummary", () => { commits: [], flags: flagsBase, }); - expect(completionCreate).toHaveBeenCalledWith( - expect.objectContaining({ model: "gpt-4o-mini" }), - ); + + expect(spy).toHaveBeenCalledWith({}); }); it("includes exclude-only commit filter copy in user message", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - const completionCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: "x" } }], - }); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { completions: { create: completionCreate } }, - } as Awaited>); + const { llmModelProvider, calls } = provideMock("x"); await generateSummary({ diffText: "d", @@ -239,43 +212,29 @@ describe("generateSummary", () => { ...flagsBase, commitMessageExcludeRegexes: ["^WIP"], }, + llmModelProvider, }); - const userMsg = completionCreate.mock.calls[0]![0].messages.find( - (m: { role: string }) => m.role === "user", - )?.content as string; + const userMsg = extractUserText(calls()[0]!); expect(userMsg).toContain("Commit message exclude regexes"); expect(userMsg).not.toContain("include regexes"); }); it("omits team line when team is blank", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - const completionCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: "x" } }], - }); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { completions: { create: completionCreate } }, - } as Awaited>); + const { llmModelProvider, calls } = provideMock("x"); await generateSummary({ diffText: "d", fileNames: [], commits: [], flags: { ...flagsBase, team: " " }, + llmModelProvider, }); - const userMsg = completionCreate.mock.calls[0]![0].messages.find( - (m: { role: string }) => m.role === "user", - )?.content as string; + const userMsg = extractUserText(calls()[0]!); expect(userMsg).not.toMatch(/^Team:/m); }); it("embeds diffSummary JSON when provided", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - const completionCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: "x" } }], - }); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { completions: { create: completionCreate } }, - } as Awaited>); + const { llmModelProvider, calls } = provideMock("x"); const diffSummary = { files: [], @@ -289,56 +248,91 @@ describe("generateSummary", () => { commits: [], flags: flagsBase, diffSummary, + llmModelProvider, }); - const userMsg = completionCreate.mock.calls[0]![0].messages.find( - (m: { role: string }) => m.role === "user", - )?.content as string; + const userMsg = extractUserText(calls()[0]!); expect(userMsg).toContain("Structured git context"); expect(userMsg).toContain('"totalFiles": 0'); }); it("returns placeholder when model returns empty content", async () => { - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { - completions: { - create: jest - .fn() - .mockResolvedValue({ choices: [{ message: { content: " " } }] }), - }, - }, - } as Awaited>); + const { llmModelProvider } = provideMock(" "); const md = await generateSummary({ diffText: "d", fileNames: [], commits: [], flags: flagsBase, + llmModelProvider, }); - expect(md).toBe("No summary generated by OpenAI."); + expect(md).toBe("No summary generated by the model."); }); - it("uses 4000 max_tokens when OPENAI_MAX_TOKENS is invalid", async () => { - const prev = process.env.OPENAI_MAX_TOKENS; - process.env.OPENAI_MAX_TOKENS = "not-int"; - jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(true); - const completionCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: "ok" } }], + it("includes 'Filters: none' copy when no commit regexes are set", async () => { + const { llmModelProvider, calls } = provideMock("x"); + + await generateSummary({ + diffText: "d", + fileNames: [], + commits: [], + flags: flagsBase, + llmModelProvider, }); - jest.spyOn(openAIConfig, "createOpenAiLikeClient").mockResolvedValue({ - chat: { completions: { create: completionCreate } }, - } as Awaited>); + const userMsg = extractUserText(calls()[0]!); + expect(userMsg).toContain("Commit message filters: none"); + expect(userMsg).toContain("single unified diff"); + }); + + it("shows '(no commits)' block when commits array is empty", async () => { + const { llmModelProvider, calls } = provideMock("x"); await generateSummary({ diffText: "d", fileNames: [], commits: [], flags: flagsBase, + llmModelProvider, }); - expect(completionCreate).toHaveBeenCalledWith( - expect.objectContaining({ max_tokens: 4000 }), - ); - if (prev === undefined) delete process.env.OPENAI_MAX_TOKENS; - else process.env.OPENAI_MAX_TOKENS = prev; + const userMsg = extractUserText(calls()[0]!); + expect(userMsg).toContain("(no commits in range after filtering)"); + expect(userMsg).toContain("(no paths in diff scope)"); + }); + + it("falls back to provider default when LLM_MAX_TOKENS is invalid", async () => { + const prev = process.env.OPENAI_MAX_TOKENS; + process.env.OPENAI_MAX_TOKENS = "not-int"; + try { + const { llmModelProvider, calls } = provideMock("ok"); + await generateSummary({ + diffText: "d", + fileNames: [], + commits: [], + flags: flagsBase, + llmModelProvider, + }); + expect(calls()[0]!.maxOutputTokens).toBe(4000); + } finally { + if (prev === undefined) delete process.env.OPENAI_MAX_TOKENS; + else process.env.OPENAI_MAX_TOKENS = prev; + } + }); + + it("honors LLM_MAX_TOKENS when valid", async () => { + const prev = process.env.LLM_MAX_TOKENS; + process.env.LLM_MAX_TOKENS = "1234"; + try { + const { llmModelProvider, calls } = provideMock("ok"); + await generateSummary({ + diffText: "d", + fileNames: [], + commits: [], + flags: flagsBase, + llmModelProvider, + }); + expect(calls()[0]!.maxOutputTokens).toBe(1234); + } finally { + if (prev === undefined) delete process.env.LLM_MAX_TOKENS; + else process.env.LLM_MAX_TOKENS = prev; + } }); }); diff --git a/test/helpers/mockLlm.ts b/test/helpers/mockLlm.ts new file mode 100644 index 0000000..a158122 --- /dev/null +++ b/test/helpers/mockLlm.ts @@ -0,0 +1,62 @@ +import type { LanguageModel } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; + +export type MockDoGenerateCall = Parameters< + MockLanguageModelV3["doGenerate"] +>[0]; + +const ZERO_USAGE = { + inputTokens: { + total: 0 as number | undefined, + noCache: 0 as number | undefined, + cacheRead: 0 as number | undefined, + cacheWrite: 0 as number | undefined, + }, + outputTokens: { + total: 0 as number | undefined, + text: 0 as number | undefined, + reasoning: 0 as number | undefined, + }, +}; + +export function makeMockModel(text: string): { + model: LanguageModel; + calls: () => MockDoGenerateCall[]; +} { + const mock = new MockLanguageModelV3({ + doGenerate: async () => ({ + content: text === "" ? [] : [{ type: "text" as const, text }], + finishReason: { unified: "stop" as const, raw: undefined }, + usage: ZERO_USAGE, + warnings: [], + }), + }); + return { model: mock, calls: () => mock.doGenerateCalls }; +} + +export function makeMockProvider(text: string): { + llmModelProvider: () => Promise; + calls: () => MockDoGenerateCall[]; +} { + const { model, calls } = makeMockModel(text); + return { llmModelProvider: async () => model, calls }; +} + +export function extractUserText(call: MockDoGenerateCall): string { + const userMessage = call.prompt.find((m) => m.role === "user"); + if (!userMessage) return ""; + const content = userMessage.content; + if (typeof content === "string") return content; + return content + .map((part) => + "text" in part && typeof part.text === "string" ? part.text : "", + ) + .join(""); +} + +export function extractSystemText(call: MockDoGenerateCall): string { + const systemMessage = call.prompt.find((m) => m.role === "system"); + if (!systemMessage) return ""; + const content = systemMessage.content; + return typeof content === "string" ? content : ""; +} diff --git a/test/index.spec.ts b/test/index.spec.ts index 31e589b..64bad2f 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,6 +1,12 @@ +import type { LanguageModel } from "ai"; + import * as gitDiff from "../src/git/gitDiff"; -import type { OpenAiLikeClient } from "../src/ai/openAIConfig"; import { summarizeGitDiff } from "../src/index"; +import { makeMockModel } from "./helpers/mockLlm"; + +function mockLlmProvider(text: string): () => Promise { + return async () => makeMockModel(text).model; +} describe("summarizeGitDiff integration", () => { const originalEnv = process.env; @@ -22,22 +28,11 @@ describe("summarizeGitDiff integration", () => { .spyOn(gitDiff, "createGitClient") .mockReturnValue(mockGit as never); - const openAiClientProvider = async (): Promise => - ({ - chat: { - completions: { - create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: "summary" } }], - }), - }, - }, - }) as OpenAiLikeClient; - await summarizeGitDiff({ from: "a", to: "b", cwd: "C:\\some\\cwd", - openAiClientProvider, + llmModelProvider: mockLlmProvider("summary"), }); expect(createSpy).toHaveBeenCalledWith("C:\\some\\cwd"); @@ -69,22 +64,11 @@ describe("summarizeGitDiff integration", () => { jest.spyOn(gitDiff, "createGitClient").mockReturnValue(mockGit); - const openAiClientProvider = async (): Promise => - ({ - chat: { - completions: { - create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: "ok" } }], - }), - }, - }, - }) as OpenAiLikeClient; - await summarizeGitDiff({ from: "x", to: "y", cwd: ".", - openAiClientProvider, + llmModelProvider: mockLlmProvider("ok"), }); expect(diff).toHaveBeenCalledWith(expect.arrayContaining(["1^!"])); diff --git a/test/llmProviders.spec.ts b/test/llmProviders.spec.ts new file mode 100644 index 0000000..2bfd067 --- /dev/null +++ b/test/llmProviders.spec.ts @@ -0,0 +1,425 @@ +jest.mock("@ai-sdk/openai", () => ({ + __esModule: true, + createOpenAI: jest.fn(() => (modelId: string) => ({ + providerName: "openai", + modelId, + })), +})); + +jest.mock("@ai-sdk/openai-compatible", () => ({ + __esModule: true, + createOpenAICompatible: jest.fn((settings: Record) => { + return (modelId: string) => ({ + providerName: "openai-compatible", + modelId, + settings, + }); + }), +})); + +jest.mock( + "@ai-sdk/anthropic", + () => ({ + __esModule: true, + createAnthropic: jest.fn(() => (modelId: string) => ({ + providerName: "anthropic", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/google", + () => ({ + __esModule: true, + createGoogleGenerativeAI: jest.fn(() => (modelId: string) => ({ + providerName: "google", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/amazon-bedrock", + () => ({ + __esModule: true, + createAmazonBedrock: jest.fn(() => (modelId: string) => ({ + providerName: "bedrock", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/mistral", + () => ({ + __esModule: true, + createMistral: jest.fn(() => (modelId: string) => ({ + providerName: "mistral", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/cohere", + () => ({ + __esModule: true, + createCohere: jest.fn(() => (modelId: string) => ({ + providerName: "cohere", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/groq", + () => ({ + __esModule: true, + createGroq: jest.fn(() => (modelId: string) => ({ + providerName: "groq", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/xai", + () => ({ + __esModule: true, + createXai: jest.fn(() => (modelId: string) => ({ + providerName: "xai", + modelId, + })), + }), + { virtual: true }, +); + +jest.mock( + "@ai-sdk/deepseek", + () => ({ + __esModule: true, + createDeepSeek: jest.fn(() => (modelId: string) => ({ + providerName: "deepseek", + modelId, + })), + }), + { virtual: true }, +); + +import { createOpenAI } from "@ai-sdk/openai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; + +import { + defaultModelForProvider, + detectLlmProvider, + isLlmProviderConfigured, + parseLlmDefaultHeadersFromEnv, + resolveLanguageModel, + resolveLlmBaseUrl, + type LlmProviderId, +} from "../src/ai/llmProviders"; + +const ENV_KEYS = [ + "LLM_PROVIDER", + "LLM_PROVIDER_NAME", + "LLM_MODEL", + "LLM_BASE_URL", + "OPENAI_BASE_URL", + "LLM_API_KEY", + "OPENAI_API_KEY", + "LLM_DEFAULT_HEADERS", + "OPENAI_DEFAULT_HEADERS", + "ANTHROPIC_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + "GOOGLE_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "DEEPSEEK_API_KEY", +]; + +function clearProviderEnv(): void { + for (const key of ENV_KEYS) delete process.env[key]; +} + +describe("llmProviders env helpers", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + clearProviderEnv(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe("resolveLlmBaseUrl", () => { + it("prefers LLM_BASE_URL over OPENAI_BASE_URL", () => { + process.env.OPENAI_BASE_URL = "https://openai.example"; + process.env.LLM_BASE_URL = " https://llm.example "; + expect(resolveLlmBaseUrl()).toBe("https://llm.example"); + }); + + it("falls back to OPENAI_BASE_URL", () => { + process.env.OPENAI_BASE_URL = "https://only-openai"; + expect(resolveLlmBaseUrl()).toBe("https://only-openai"); + }); + + it("returns undefined when unset", () => { + expect(resolveLlmBaseUrl()).toBeUndefined(); + }); + }); + + describe("parseLlmDefaultHeadersFromEnv", () => { + it("returns undefined when no headers set", () => { + expect(parseLlmDefaultHeadersFromEnv()).toBeUndefined(); + }); + + it("merges OPENAI_DEFAULT_HEADERS with LLM_DEFAULT_HEADERS override", () => { + process.env.OPENAI_DEFAULT_HEADERS = JSON.stringify({ + "X-A": "1", + "X-B": "old", + }); + process.env.LLM_DEFAULT_HEADERS = JSON.stringify({ + "X-B": "new", + "X-C": "3", + }); + expect(parseLlmDefaultHeadersFromEnv()).toEqual({ + "X-A": "1", + "X-B": "new", + "X-C": "3", + }); + }); + + it("returns undefined for invalid JSON", () => { + process.env.OPENAI_DEFAULT_HEADERS = "{not json"; + expect(parseLlmDefaultHeadersFromEnv()).toBeUndefined(); + }); + + it("ignores non-object JSON (arrays)", () => { + process.env.OPENAI_DEFAULT_HEADERS = "[1,2,3]"; + expect(parseLlmDefaultHeadersFromEnv()).toBeUndefined(); + }); + + it("ignores non-string header values", () => { + process.env.OPENAI_DEFAULT_HEADERS = JSON.stringify({ + "X-Num": 42, + "X-Ok": "yes", + }); + expect(parseLlmDefaultHeadersFromEnv()).toEqual({ "X-Ok": "yes" }); + }); + }); + + describe("detectLlmProvider", () => { + it("returns undefined when nothing configured", () => { + expect(detectLlmProvider()).toBeUndefined(); + expect(isLlmProviderConfigured()).toBe(false); + }); + + it("honors explicit LLM_PROVIDER", () => { + process.env.LLM_PROVIDER = "anthropic"; + expect(detectLlmProvider()).toBe("anthropic"); + }); + + it("ignores unknown LLM_PROVIDER values and falls back", () => { + process.env.LLM_PROVIDER = "made-up"; + process.env.OPENAI_API_KEY = "sk-x"; + expect(detectLlmProvider()).toBe("openai"); + }); + + it("auto-detects openai-compatible from base URL", () => { + process.env.OPENAI_BASE_URL = "https://gateway.example/v1"; + expect(detectLlmProvider()).toBe("openai-compatible"); + }); + + it("auto-detects openai from API key", () => { + process.env.OPENAI_API_KEY = "sk-test"; + expect(detectLlmProvider()).toBe("openai"); + }); + + it("auto-detects other providers from their keys", () => { + const cases: Array<[string, LlmProviderId]> = [ + ["ANTHROPIC_API_KEY", "anthropic"], + ["GOOGLE_GENERATIVE_AI_API_KEY", "google"], + ["GOOGLE_API_KEY", "google"], + ["MISTRAL_API_KEY", "mistral"], + ["COHERE_API_KEY", "cohere"], + ["GROQ_API_KEY", "groq"], + ["XAI_API_KEY", "xai"], + ["DEEPSEEK_API_KEY", "deepseek"], + ]; + for (const [envKey, provider] of cases) { + clearProviderEnv(); + process.env[envKey] = "k"; + expect(detectLlmProvider()).toBe(provider); + } + }); + + it("falls back to openai when only default headers are set", () => { + process.env.LLM_DEFAULT_HEADERS = JSON.stringify({ + Authorization: "Bearer sk-x", + }); + expect(detectLlmProvider()).toBe("openai"); + }); + }); + + describe("defaultModelForProvider", () => { + it("returns a non-empty model id for every provider", () => { + const providers: LlmProviderId[] = [ + "openai", + "openai-compatible", + "anthropic", + "google", + "bedrock", + "mistral", + "cohere", + "groq", + "xai", + "deepseek", + ]; + for (const p of providers) { + expect(defaultModelForProvider(p).length).toBeGreaterThan(0); + } + }); + }); +}); + +describe("resolveLanguageModel", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + clearProviderEnv(); + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("throws when no provider is resolvable", async () => { + await expect(resolveLanguageModel()).rejects.toThrow( + /No LLM provider could be resolved/, + ); + }); + + it("uses openai provider with API key and optional headers", async () => { + process.env.OPENAI_API_KEY = "sk-real"; + process.env.OPENAI_DEFAULT_HEADERS = JSON.stringify({ "X-Custom": "1" }); + const model = (await resolveLanguageModel({ model: "gpt-test" })) as unknown as { + providerName: string; + modelId: string; + }; + expect(model.modelId).toBe("gpt-test"); + expect(model.providerName).toBe("openai"); + expect(createOpenAI).toHaveBeenCalledWith({ + apiKey: "sk-real", + headers: { "X-Custom": "1" }, + }); + }); + + it("uses openai provider with no init when only env var present", async () => { + process.env.LLM_PROVIDER = "openai"; + const model = (await resolveLanguageModel()) as unknown as { + providerName: string; + modelId: string; + }; + expect(model.providerName).toBe("openai"); + expect(model.modelId).toBe(defaultModelForProvider("openai")); + expect(createOpenAI).toHaveBeenCalledWith({}); + }); + + it("uses openai-compatible provider with baseURL, apiKey, and headers", async () => { + process.env.LLM_BASE_URL = "https://gateway.example/v1"; + process.env.LLM_API_KEY = "sk-llm"; + process.env.LLM_DEFAULT_HEADERS = JSON.stringify({ + "x-company-rbac": "token", + }); + process.env.LLM_PROVIDER_NAME = "corp-gateway"; + + const model = (await resolveLanguageModel({ model: "router/gpt" })) as unknown as { + providerName: string; + modelId: string; + }; + expect(model.modelId).toBe("router/gpt"); + expect(createOpenAICompatible).toHaveBeenCalledWith({ + name: "corp-gateway", + baseURL: "https://gateway.example/v1", + apiKey: "sk-llm", + headers: { "x-company-rbac": "token" }, + }); + }); + + it("uses LLM_MODEL env when options.model is absent", async () => { + process.env.OPENAI_API_KEY = "sk-k"; + process.env.LLM_MODEL = "gpt-4.1-mini"; + const model = (await resolveLanguageModel()) as unknown as { modelId: string }; + expect(model.modelId).toBe("gpt-4.1-mini"); + }); + + it("throws when openai-compatible is selected without a base URL", async () => { + process.env.LLM_PROVIDER = "openai-compatible"; + await expect(resolveLanguageModel()).rejects.toThrow( + /requires LLM_BASE_URL/, + ); + }); + + it("dispatches to each optional provider", async () => { + const cases: Array<[LlmProviderId, string]> = [ + ["anthropic", "ANTHROPIC_API_KEY"], + ["google", "GOOGLE_GENERATIVE_AI_API_KEY"], + ["mistral", "MISTRAL_API_KEY"], + ["cohere", "COHERE_API_KEY"], + ["groq", "GROQ_API_KEY"], + ["xai", "XAI_API_KEY"], + ["deepseek", "DEEPSEEK_API_KEY"], + ]; + for (const [provider, envKey] of cases) { + clearProviderEnv(); + process.env[envKey] = "secret"; + const model = (await resolveLanguageModel({ provider })) as unknown as { + providerName: string; + modelId: string; + }; + expect(model.providerName).toBe(provider); + expect(model.modelId).toBe(defaultModelForProvider(provider)); + } + }); + + it("dispatches to bedrock without requiring an api key env", async () => { + process.env.LLM_PROVIDER = "bedrock"; + const model = (await resolveLanguageModel()) as unknown as { + providerName: string; + modelId: string; + }; + expect(model.providerName).toBe("bedrock"); + expect(model.modelId).toBe(defaultModelForProvider("bedrock")); + }); + + it("wraps missing optional provider package with helpful message", async () => { + jest.resetModules(); + jest.doMock( + "@ai-sdk/anthropic", + () => { + throw new Error("Cannot find module '@ai-sdk/anthropic'"); + }, + { virtual: true }, + ); + const { resolveLanguageModel: resolveAgain } = await import( + "../src/ai/llmProviders" + ); + process.env.LLM_PROVIDER = "anthropic"; + await expect(resolveAgain()).rejects.toThrow( + /Failed to load optional provider package "@ai-sdk\/anthropic"/, + ); + }); +}); diff --git a/test/openAIConfig.spec.ts b/test/openAIConfig.spec.ts deleted file mode 100644 index 195929c..0000000 --- a/test/openAIConfig.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - parseLlmDefaultHeadersFromEnv, - resolveLlmBaseUrl, - resolveOpenAiLikeClientInit, - shouldUseLlmGateway, - splitPromotableAuthorizationFromHeaders, -} from "../src/ai/openAIConfig"; - -const originalEnv = process.env; - -function resetEnv(): void { - process.env = { ...originalEnv }; -} - -describe("openAIConfig", () => { - beforeEach(() => { - resetEnv(); - delete process.env.LLM_BASE_URL; - delete process.env.OPENAI_BASE_URL; - delete process.env.LLM_API_KEY; - delete process.env.OPENAI_API_KEY; - delete process.env.OPENAI_DEFAULT_HEADERS; - delete process.env.LLM_DEFAULT_HEADERS; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - describe("resolveLlmBaseUrl", () => { - it("prefers LLM_BASE_URL over OPENAI_BASE_URL", () => { - process.env.OPENAI_BASE_URL = "https://openai.example"; - process.env.LLM_BASE_URL = " https://llm.example "; - expect(resolveLlmBaseUrl()).toBe("https://llm.example"); - }); - - it("falls back to OPENAI_BASE_URL", () => { - process.env.OPENAI_BASE_URL = "https://only-openai"; - expect(resolveLlmBaseUrl()).toBe("https://only-openai"); - }); - - it("returns undefined when unset", () => { - expect(resolveLlmBaseUrl()).toBeUndefined(); - }); - }); - - describe("parseLlmDefaultHeadersFromEnv", () => { - it("returns undefined when no headers set", () => { - expect(parseLlmDefaultHeadersFromEnv()).toBeUndefined(); - }); - - it("merges OPENAI_DEFAULT_HEADERS with LLM_DEFAULT_HEADERS override", () => { - process.env.OPENAI_DEFAULT_HEADERS = JSON.stringify({ - "X-A": "1", - "X-B": "old", - }); - process.env.LLM_DEFAULT_HEADERS = JSON.stringify({ - "X-B": "new", - "X-C": "3", - }); - expect(parseLlmDefaultHeadersFromEnv()).toEqual({ - "X-A": "1", - "X-B": "new", - "X-C": "3", - }); - }); - - it("returns undefined for invalid JSON", () => { - process.env.OPENAI_DEFAULT_HEADERS = "{not json"; - expect(parseLlmDefaultHeadersFromEnv()).toBeUndefined(); - }); - - it("ignores non-object JSON (arrays)", () => { - process.env.OPENAI_DEFAULT_HEADERS = "[1,2,3]"; - expect(parseLlmDefaultHeadersFromEnv()).toBeUndefined(); - }); - - it("ignores non-string header values", () => { - process.env.OPENAI_DEFAULT_HEADERS = JSON.stringify({ - "X-Num": 42, - "X-Ok": "yes", - }); - expect(parseLlmDefaultHeadersFromEnv()).toEqual({ "X-Ok": "yes" }); - }); - }); - - describe("splitPromotableAuthorizationFromHeaders", () => { - it("returns headers unchanged when no Authorization", () => { - const h = { "X-Custom": "v" }; - expect(splitPromotableAuthorizationFromHeaders(h)).toEqual({ - defaultHeaders: h, - }); - }); - - it("promotes raw sk- token from Authorization", () => { - const sk = "sk-test123456789012345678901234567890"; - const result = splitPromotableAuthorizationFromHeaders({ - Authorization: sk, - }); - expect(result.apiKeyFromAuthHeader).toBe(sk); - expect(result.defaultHeaders).toEqual({}); - }); - - it("promotes Bearer sk- token and strips header", () => { - const sk = "sk-abc"; - const result = splitPromotableAuthorizationFromHeaders({ - Authorization: `Bearer ${sk}`, - }); - expect(result.apiKeyFromAuthHeader).toBe(sk); - expect(result.defaultHeaders).toEqual({}); - }); - - it("does not promote non-key Authorization values", () => { - const h = { Authorization: "Basic dXNlcjpwYXNz" }; - expect(splitPromotableAuthorizationFromHeaders(h)).toEqual({ - defaultHeaders: h, - }); - }); - - it("returns headers unchanged when Authorization header is empty", () => { - const h = { Authorization: "" }; - expect(splitPromotableAuthorizationFromHeaders(h)).toEqual({ - defaultHeaders: h, - }); - }); - }); - - describe("shouldUseLlmGateway", () => { - it("is true when OPENAI_API_KEY is set", () => { - process.env.OPENAI_API_KEY = "sk-key"; - expect(shouldUseLlmGateway()).toBe(true); - }); - - it("is true when LLM_API_KEY is set", () => { - process.env.LLM_API_KEY = "sk-llm"; - expect(shouldUseLlmGateway()).toBe(true); - }); - - it("is true when base URL is set without key", () => { - process.env.OPENAI_BASE_URL = "https://proxy.local/v1"; - expect(shouldUseLlmGateway()).toBe(true); - }); - - it("is true when default headers JSON is non-empty", () => { - process.env.LLM_DEFAULT_HEADERS = JSON.stringify({ - Authorization: "Bearer sk-from-header", - }); - expect(shouldUseLlmGateway()).toBe(true); - }); - - it("is false when nothing configured", () => { - expect(shouldUseLlmGateway()).toBe(false); - }); - }); - - describe("resolveOpenAiLikeClientInit", () => { - it("uses env API key and optional base URL", () => { - process.env.OPENAI_API_KEY = "sk-real"; - process.env.LLM_BASE_URL = "https://custom"; - expect(resolveOpenAiLikeClientInit()).toEqual({ - apiKey: "sk-real", - baseURL: "https://custom", - }); - }); - - it("uses unused placeholder when no key and no promotable auth in headers", () => { - expect(resolveOpenAiLikeClientInit()).toEqual({ apiKey: "unused" }); - }); - - it("prefers LLM_API_KEY over OPENAI_API_KEY", () => { - process.env.OPENAI_API_KEY = "sk-openai"; - process.env.LLM_API_KEY = "sk-llm-wins"; - expect(resolveOpenAiLikeClientInit().apiKey).toBe("sk-llm-wins"); - }); - - it("promotes sk- token from default headers when env API key is empty", () => { - process.env.LLM_DEFAULT_HEADERS = JSON.stringify({ - Authorization: "sk-from-headers-only", - }); - expect(resolveOpenAiLikeClientInit()).toEqual({ - apiKey: "sk-from-headers-only", - }); - }); - - it("uses defaultHeaders with env API key when both are set", () => { - process.env.OPENAI_API_KEY = "sk-main"; - process.env.OPENAI_DEFAULT_HEADERS = JSON.stringify({ "X-Custom": "1" }); - expect(resolveOpenAiLikeClientInit()).toEqual({ - apiKey: "sk-main", - defaultHeaders: { "X-Custom": "1" }, - }); - }); - }); -}); diff --git a/test/openAiSdk.spec.ts b/test/openAiSdk.spec.ts deleted file mode 100644 index 4feb140..0000000 --- a/test/openAiSdk.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -jest.mock("openai", () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ constructed: true })), -})); - -import OpenAI from "openai"; - -import { - createOpenAiLikeClient, - resolveOpenAiLikeClientInit, -} from "../src/ai/openAIConfig"; - -const originalEnv = process.env; - -describe("createOpenAiLikeClient", () => { - beforeEach(() => { - process.env = { ...originalEnv }; - process.env.OPENAI_API_KEY = "sk-test-openai-sdk-spec"; - delete process.env.LLM_BASE_URL; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - it("loads the OpenAI SDK and constructs a client with resolved init", async () => { - const init = resolveOpenAiLikeClientInit(); - const client = await createOpenAiLikeClient(); - expect(client).toEqual({ constructed: true }); - expect(OpenAI).toHaveBeenCalledWith(init); - }); -}); diff --git a/test/summarizeGitDiff.spec.ts b/test/summarizeGitDiff.spec.ts index 4453886..5fba2ed 100644 --- a/test/summarizeGitDiff.spec.ts +++ b/test/summarizeGitDiff.spec.ts @@ -1,8 +1,9 @@ import { join } from "node:path"; +import type { LanguageModel } from "ai"; import type { SimpleGit } from "simple-git"; -import type { OpenAiLikeClient } from "../src/ai/openAIConfig"; import { summarizeGitDiff } from "../src/index"; +import { makeMockModel } from "./helpers/mockLlm"; function createMockGit(repoRoot: string): SimpleGit { const diff = jest.fn().mockImplementation(async (args: string[]) => { @@ -25,25 +26,14 @@ function createMockGit(repoRoot: string): SimpleGit { } as unknown as SimpleGit; } -function mockOpenAiClient( - summaryMarkdown: string, -): () => Promise { - return async () => - ({ - chat: { - completions: { - create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: summaryMarkdown } }], - }), - }, - }, - }) as OpenAiLikeClient; +function mockLlmProvider(text: string): () => Promise { + return async () => makeMockModel(text).model; } describe("summarizeGitDiff", () => { const repoRoot = join(__dirname, "fixture-repo-root"); - it("aggregates git calls and returns LLM summary via openAiClientProvider", async () => { + it("aggregates git calls and returns LLM summary via llmModelProvider", async () => { const git = createMockGit(repoRoot); const md = await summarizeGitDiff({ @@ -53,9 +43,7 @@ describe("summarizeGitDiff", () => { teamName: "Infra", excludeFolders: ["node_modules"], commitMessageExcludeRegexes: ["^chore:"], - openAiClientProvider: mockOpenAiClient( - "# Infra Summary\nBody from model", - ), + llmModelProvider: mockLlmProvider("# Infra Summary\nBody from model"), }); expect(git.log).toHaveBeenCalledWith({ from: "main", to: "topic" }); @@ -71,7 +59,7 @@ describe("summarizeGitDiff", () => { to: "b", git, commitMessageIncludeRegexes: ["."], - openAiClientProvider: mockOpenAiClient("ok"), + llmModelProvider: mockLlmProvider("ok"), }); const diffCalls = (git.diff as jest.Mock).mock.calls.map( diff --git a/tsconfig.json b/tsconfig.json index 082f434..01c448d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "removeComments": true, + "skipLibCheck": true, "baseUrl": ".", "types": ["node", "jest"], "paths": {