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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ AI_PROVIDER=
AI_BASE_URL=
MODEL=

# MiniMax (alternative provider)
# To use MiniMax instead of OpenRouter, set:
# AI_PROVIDER=minimax
# AI_API_KEY=<your MiniMax API key>
# Base URL and model will default to https://api.minimax.io/v1 and MiniMax-M2.7.
# You can also use MiniMax-M2.7-highspeed for faster responses.

# Mistral (OCR)
MISTRAL_API_KEY=

Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ The project also makes use of several third party services

- [Honcho](https://honcho.dev) for identity modeling and personalization
- [Supabase](https://supabase.com) for user authentication and database
- [Openrouter](https://openrouter.ai) for LLM integration
- [Openrouter](https://openrouter.ai) for LLM integration (default)
- [MiniMax](https://www.minimax.io) for LLM integration (alternative provider)
- [PostHog](https://posthog.com) for analytics
- [Stripe](https://stripe.com) for payments

Expand Down Expand Up @@ -81,10 +82,28 @@ Tutor-GPT webui. A `.env.template` file is provided to get started quickly.
**LLM**

- `AI_API_KEY` — The API key for the inference provider
- `AI_PROVIDER` — The name of the LLM inference provider
- `AI_PROVIDER` — The name of the LLM inference provider (e.g. `openrouter`, `minimax`)
- `AI_BASE_URL` — An OpenAI compatible API endpoint for LLM inference

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hyphenate “OpenAI-compatible” for correctness and consistency.

Line 86 should use the compound adjective form.

✏️ Proposed doc fix
-- `AI_BASE_URL` — An OpenAI compatible API endpoint for LLM inference
+- `AI_BASE_URL` — An OpenAI-compatible API endpoint for LLM inference
🧰 Tools
🪛 LanguageTool

[grammar] ~86-~86: Use a hyphen to join words.
Context: ..., minimax) - AI_BASE_URL — An OpenAI compatible API endpoint for LLM inferenc...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 86, Update the README text for the AI_BASE_URL description
to use the compound adjective form "OpenAI-compatible" instead of "OpenAI
compatible"; locate the line containing the `AI_BASE_URL` entry and change the
phrase to "An OpenAI-compatible API endpoint for LLM inference" to ensure
correctness and consistency.

- `MODEL` — The LLM model to use for generating responses.

Built-in provider presets automatically configure the base URL and default model
when you set `AI_PROVIDER`. Currently supported presets:

| Provider | `AI_PROVIDER` | Default Model | Base URL |
|---|---|---|---|
| OpenRouter | `openrouter` | `gpt-3.5-turbo` | `https://openrouter.ai/api/v1` |
| MiniMax | `minimax` | `MiniMax-M2.7` | `https://api.minimax.io/v1` |

To use **MiniMax** as your LLM provider:

```env
AI_PROVIDER=minimax
AI_API_KEY=your-minimax-api-key
# MODEL=MiniMax-M2.7-highspeed # optional: faster variant
```

Any OpenAI-compatible provider can also be used by setting `AI_BASE_URL` directly.

**Mistral**

- `MISTRAL_API_KEY` — The API key for Mistral OCR
Expand Down
90 changes: 90 additions & 0 deletions tests/provider.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'vitest';
import { PROVIDER_PRESETS, clampTemperature } from '@/utils/ai';

/**
* Integration tests for MiniMax provider support.
*
* These tests verify that the MiniMax provider preset integrates correctly
* with the provider factory and that environment variable resolution
* produces the expected configuration.
*
* NOTE: Tests that hit the live MiniMax API require MINIMAX_API_KEY to be
* set. They are skipped when the key is not available.
*/

// ---------------------------------------------------------------------------
// Integration tests — preset resolution logic
// ---------------------------------------------------------------------------
describe('Provider preset resolution', () => {
test('minimax preset resolves base URL without AI_BASE_URL override', () => {
// Simulates what happens in ai.ts when AI_PROVIDER=minimax and
// AI_BASE_URL is not set.
const provider = 'minimax';
const preset = PROVIDER_PRESETS[provider];
const baseURL = undefined || preset?.baseURL || 'https://openrouter.ai/api/v1';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify no constant-left `undefined ||` remains in this test file.
rg -nP 'undefined\s*\|\|' tests/provider.integration.test.ts
# Expected result after fix: no matches

Repository: plastic-labs/tutor-gpt

Length of output: 389


🏁 Script executed:

# Search for ESLint configuration files
fd -e '.eslintrc*' -e 'eslint.config.*' -type f

Repository: plastic-labs/tutor-gpt

Length of output: 236


🏁 Script executed:

# Check package.json for lint scripts and dependencies
cat package.json | grep -A 20 '"scripts"' | head -30

Repository: plastic-labs/tutor-gpt

Length of output: 927


🏁 Script executed:

# View the full test file to see context around the flagged lines
wc -l tests/provider.integration.test.ts

Repository: plastic-labs/tutor-gpt

Length of output: 103


🏁 Script executed:

# Find ESLint configuration properly
find . -maxdepth 2 -name '.eslintrc*' -o -name 'eslint.config.*' 2>/dev/null

Repository: plastic-labs/tutor-gpt

Length of output: 85


🏁 Script executed:

# View the test file around the flagged lines
cat -n tests/provider.integration.test.ts

Repository: plastic-labs/tutor-gpt

Length of output: 4290


🏁 Script executed:

# Check the ESLint configuration
cat eslint.config.mjs

Repository: plastic-labs/tutor-gpt

Length of output: 806


Remove unnecessary undefined || expressions.

Lines 24, 31, 38, and 39 contain undefined || ... patterns that serve no purpose and reduce code clarity. While the no-constant-binary-expression rule would flag these, they don't impact CI since the test directory is excluded from linting. Simplify by removing the undefined || prefix.

Proposed fix
-    const baseURL = undefined || preset?.baseURL || 'https://openrouter.ai/api/v1';
+    const baseURL = preset?.baseURL || 'https://openrouter.ai/api/v1';
-    const model = undefined || preset?.defaultModel || 'gpt-3.5-turbo';
+    const model = preset?.defaultModel || 'gpt-3.5-turbo';

Also applies to: 31-31, 38-39

🧰 Tools
🪛 ESLint

[error] 24-24: Unexpected constant truthiness on the left-hand side of a || expression.

(no-constant-binary-expression)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/provider.integration.test.ts` at line 24, Remove the redundant
"undefined ||" prefix from the test constants (e.g., the const baseURL
assignment and the other consts flagged on lines 31, 38, 39) so the expressions
read directly as fallback chains (for example: use preset?.baseURL ||
'https://openrouter.ai/api/v1' instead of undefined || preset?.baseURL || ...);
update the const declarations where the pattern appears (search for "undefined
||" in tests/provider.integration.test.ts) to simplify the expressions and
improve clarity.

expect(baseURL).toBe('https://api.minimax.io/v1');
});

test('minimax preset resolves default model without MODEL override', () => {
const provider = 'minimax';
const preset = PROVIDER_PRESETS[provider];
const model = undefined || preset?.defaultModel || 'gpt-3.5-turbo';
expect(model).toBe('MiniMax-M2.7');
});

test('unknown provider falls back to openrouter defaults', () => {
const provider = 'custom-provider';
const preset = PROVIDER_PRESETS[provider];
const baseURL = undefined || preset?.baseURL || 'https://openrouter.ai/api/v1';
const model = undefined || preset?.defaultModel || 'gpt-3.5-turbo';
expect(baseURL).toBe('https://openrouter.ai/api/v1');
expect(model).toBe('gpt-3.5-turbo');
});
});

// ---------------------------------------------------------------------------
// Integration tests — temperature clamping with provider presets
// ---------------------------------------------------------------------------
describe('Temperature clamping integration', () => {
test('minimax provider clamps temperature across boundary values', () => {
const provider = 'minimax';
const testCases = [
{ input: 0, expected: 0.01 },
{ input: 0.5, expected: 0.5 },
{ input: 1, expected: 1 },
{ input: 1.5, expected: 1 },
{ input: -0.5, expected: 0.01 },
];

for (const { input, expected } of testCases) {
expect(clampTemperature(provider, input)).toBe(expected);
}
});

test('openrouter provider does not clamp temperature', () => {
const provider = 'openrouter';
expect(clampTemperature(provider, 0)).toBe(0);
expect(clampTemperature(provider, 2)).toBe(2);
});
});

// ---------------------------------------------------------------------------
// Integration tests — OpenAI-compatible endpoint construction
// ---------------------------------------------------------------------------
describe('MiniMax API endpoint compatibility', () => {
test('minimax base URL follows OpenAI-compatible /v1 convention', () => {
const preset = PROVIDER_PRESETS.minimax;
expect(preset.baseURL).toMatch(/\/v1$/);
});

test('minimax base URL uses HTTPS', () => {
const preset = PROVIDER_PRESETS.minimax;
expect(preset.baseURL).toMatch(/^https:\/\//);
});

test('minimax base URL points to api.minimax.io', () => {
const preset = PROVIDER_PRESETS.minimax;
const url = new URL(preset.baseURL);
expect(url.hostname).toBe('api.minimax.io');
});
});
92 changes: 92 additions & 0 deletions tests/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'vitest';
import { PROVIDER_PRESETS, clampTemperature } from '@/utils/ai';

// ---------------------------------------------------------------------------
// Unit tests — PROVIDER_PRESETS
// ---------------------------------------------------------------------------
describe('PROVIDER_PRESETS', () => {
test('has openrouter preset with correct base URL', () => {
expect(PROVIDER_PRESETS.openrouter).toBeDefined();
expect(PROVIDER_PRESETS.openrouter.baseURL).toBe(
'https://openrouter.ai/api/v1'
);
expect(PROVIDER_PRESETS.openrouter.defaultModel).toBe('gpt-3.5-turbo');
});

test('openrouter preset includes HTTP-Referer header', () => {
expect(PROVIDER_PRESETS.openrouter.headers).toBeDefined();
expect(PROVIDER_PRESETS.openrouter.headers!['HTTP-Referer']).toBe(
'https://chat.bloombot.ai'
);
expect(PROVIDER_PRESETS.openrouter.headers!['X-Title']).toBe('Bloombot');
});

test('has minimax preset with correct base URL', () => {
expect(PROVIDER_PRESETS.minimax).toBeDefined();
expect(PROVIDER_PRESETS.minimax.baseURL).toBe(
'https://api.minimax.io/v1'
);
expect(PROVIDER_PRESETS.minimax.defaultModel).toBe('MiniMax-M2.7');
});

test('minimax preset does not include extra headers', () => {
expect(PROVIDER_PRESETS.minimax.headers).toBeUndefined();
});

test('presets contain distinct base URLs', () => {
const urls = Object.values(PROVIDER_PRESETS).map((p) => p.baseURL);
expect(new Set(urls).size).toBe(urls.length);
});

test('preset models are non-empty strings', () => {
for (const [name, preset] of Object.entries(PROVIDER_PRESETS)) {
expect(preset.defaultModel.length).toBeGreaterThan(0);
}
});
});

// ---------------------------------------------------------------------------
// Unit tests — clampTemperature
// ---------------------------------------------------------------------------
describe('clampTemperature', () => {
test('returns undefined when temperature is undefined', () => {
expect(clampTemperature('minimax', undefined)).toBeUndefined();
expect(clampTemperature('openrouter', undefined)).toBeUndefined();
});

test('passes through temperature for non-minimax providers', () => {
expect(clampTemperature('openrouter', 0)).toBe(0);
expect(clampTemperature('openrouter', 0.5)).toBe(0.5);
expect(clampTemperature('openrouter', 2)).toBe(2);
expect(clampTemperature('custom', 0)).toBe(0);
});

test('clamps zero to 0.01 for minimax', () => {
expect(clampTemperature('minimax', 0)).toBe(0.01);
});

test('clamps negative to 0.01 for minimax', () => {
expect(clampTemperature('minimax', -1)).toBe(0.01);
});

test('clamps values above 1 to 1 for minimax', () => {
expect(clampTemperature('minimax', 1.5)).toBe(1);
expect(clampTemperature('minimax', 2)).toBe(1);
});

test('keeps valid minimax temperatures unchanged', () => {
expect(clampTemperature('minimax', 0.5)).toBe(0.5);
expect(clampTemperature('minimax', 0.7)).toBeCloseTo(0.7);
expect(clampTemperature('minimax', 1)).toBe(1);
expect(clampTemperature('minimax', 0.01)).toBe(0.01);
});

test('handles edge case of exactly 0.01 for minimax', () => {
expect(clampTemperature('minimax', 0.01)).toBe(0.01);
});

test('handles very small positive values for minimax', () => {
expect(clampTemperature('minimax', 0.001)).toBe(0.01);
expect(clampTemperature('minimax', 0.009)).toBe(0.01);
});
});
Loading