Files
zeekay 83464f5d54 One default model on every surface: zen5
Office, Outlook, PDF, Clio, Meetings, Google Workspace and Salesforce
defaulted to zen-1, Teams offered zen-eco-3b, zen-mini-8b and zen-4b,
and JetBrains defaulted to claude-3-5-sonnet-20241022 with a fallback
list of retired upstream ids. None of those are in api.hanzo.ai/v1/models,
so a first request on each of them named a model the gateway does not
serve.

zen5 is the id the catalog itself marks as the default: catalog.hanzo.ai
/v1/pricing lists it as "Zen5 (Default)", "the everyday Zen5 chat model",
and nineteen other surfaces here already defaulted to it. Teams now offers
the catalog's Zen 5 family with zen5 first. JetBrains preselects zen5
when the live list carries it, and offers only zen5 when it cannot load
the list, rather than a fabricated one.

Claude-Session: https://claude.ai/code/session_01Dc8DXc3fEQCRHUa6XWK4Ku
2026-09-29 12:29:56 -07:00

79 lines
2.6 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { complete, listModels, type AiClient } from '../src/hanzo';
// A hand-built stub of the exact client slice hanzo.ts uses, so we assert on
// the request we shape and the response we read — no network.
function stubClient(overrides: Partial<AiClient> = {}): {
client: AiClient;
create: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
} {
const create = vi.fn(async () => ({
choices: [{ message: { content: 'hello from hanzo' } }],
}));
const list = vi.fn(async () => ({ data: [{ id: 'zen5-flash' }, { id: 'zen5' }] }));
const client: AiClient = {
chat: { completions: { create } },
models: { list },
...overrides,
} as AiClient;
return { client, create, list };
}
describe('complete', () => {
it('shapes the request with model, messages, and max_tokens', async () => {
const { client, create } = stubClient();
const text = await complete(client, {
model: 'zen5',
messages: [{ role: 'user', content: 'hi' }],
maxTokens: 256,
temperature: 0.3,
});
expect(text).toBe('hello from hanzo');
expect(create).toHaveBeenCalledWith({
model: 'zen5',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
temperature: 0.3,
});
});
it('omits temperature and max_tokens when not provided', async () => {
const { client, create } = stubClient();
await complete(client, { model: 'zen5-flash', messages: [] });
expect(create).toHaveBeenCalledWith({ model: 'zen5-flash', messages: [] });
});
it('throws on an empty completion', async () => {
const { client } = stubClient({
chat: { completions: { create: vi.fn(async () => ({ choices: [] })) } },
} as Partial<AiClient>);
await expect(
complete(client, { model: 'x', messages: [] }),
).rejects.toThrow(/empty completion/i);
});
it('throws when the message content is null', async () => {
const { client } = stubClient({
chat: {
completions: { create: vi.fn(async () => ({ choices: [{ message: { content: null } }] })) },
},
} as Partial<AiClient>);
await expect(complete(client, { model: 'x', messages: [] })).rejects.toThrow();
});
});
describe('listModels', () => {
it('returns the model ids from the catalog', async () => {
const { client } = stubClient();
expect(await listModels(client)).toEqual(['zen5-flash', 'zen5']);
});
it('returns [] when the catalog is shaped unexpectedly', async () => {
const { client } = stubClient({
models: { list: vi.fn(async () => ({})) },
} as Partial<AiClient>);
expect(await listModels(client)).toEqual([]);
});
});