Skip to content
Merged
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
1 change: 1 addition & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
43 changes: 40 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@react-pdf/renderer": "^4.8.1",
"ai-kit": "github:bitbaum/ai-kit#v0.5.0",
"chart.js": "^4.5.1",
"drizzle-orm": "^0.45.1",
"fuse.js": "^7.1.0",
Expand Down
108 changes: 106 additions & 2 deletions scripts/lib/groq-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
* sending a nonsense one looks exactly the same from the outside. That is the
* argument for testing the resolution rather than the ids.
*/
import { describe, expect, it } from 'vitest';
import { GROQ_MODELS, resolveModel } from './groq-client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { freeChain, providerModels } from 'ai-kit';
import { GROQ_MODELS, callGroq, resolveModel } from './groq-client';

const CHAIN_MODEL_COUNT = providerModels(freeChain('HIRNLI')[0]).length;

describe('resolveModel', () => {
it('turns a size alias into a real id', () => {
Expand Down Expand Up @@ -71,3 +74,104 @@ describe('the model ids themselves', () => {
expect(GROQ_MODELS.small).not.toBe(GROQ_MODELS.large);
});
});

/**
* `callGroq` used to call ONE pinned model, once — the exact shape that let a
* single Groq retirement take down every route and script in this repo at
* the same moment. It now walks `ai-kit`'s fallback chain for Groq, so these
* pin the demote-on-failure behaviour the fix actually depends on.
*/
describe('callGroq — fallback across the chain', () => {
const originalKey = process.env.GROQ_API_KEY;
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
process.env.GROQ_API_KEY = 'test-key';
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});

afterEach(() => {
process.env.GROQ_API_KEY = originalKey;
vi.unstubAllGlobals();
});

it('demotes to the next model in the chain when the first is retired', async () => {
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '{"error":{"code":"model_not_found"}}',
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ choices: [{ message: { content: 'second model answered' } }] }),
});

const result = await callGroq('system', 'user');

expect(result).toEqual({ ok: true, content: 'second model answered', usage: undefined });
expect(fetchMock).toHaveBeenCalledTimes(2);
// The first attempt must have asked for the FIRST chain model, and the
// second for a DIFFERENT one — a retry that resends the same id is not a
// fallback.
const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body);
const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body);
expect(firstBody.model).not.toBe(secondBody.model);
});

it('reports every model it tried when the whole chain is exhausted', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 401,
text: async () => 'invalid_api_key',
});

const result = await callGroq('system', 'user');

expect(result.ok).toBe(false);
// Every model in the chain must have been tried — not just the first.
expect(fetchMock).toHaveBeenCalledTimes(CHAIN_MODEL_COUNT);
// Every link's failure should be named, not just the last one tried.
expect(result.error).toMatch(/link\(s\) failed/);
});

it('an explicit model is called once and alone, not folded into the chain', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
});

const result = await callGroq('system', 'user', { model: 'small' });

expect(result.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
// The alias must still be resolved, exactly as before this change.
expect(body.model).toBe(GROQ_MODELS.small);
});

it('an explicit model that fails does NOT fall through to the rest of the chain', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
text: async () => 'model_not_found',
});

const result = await callGroq('system', 'user', { model: 'small' });

expect(result.ok).toBe(false);
// Naming a model and silently answering from a different one would be
// worse than failing — so exactly one attempt, not a walk.
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('reports missing key without calling fetch at all', async () => {
process.env.GROQ_API_KEY = '';

const result = await callGroq('system', 'user');

expect(result).toEqual({ ok: false, error: 'GROQ_API_KEY not set in environment' });
expect(fetchMock).not.toHaveBeenCalled();
});
});
Loading