diff --git a/packages/ai/chutes/src/index.test.ts b/packages/ai/chutes/src/index.test.ts index f43ad207..5cb84823 100644 --- a/packages/ai/chutes/src/index.test.ts +++ b/packages/ai/chutes/src/index.test.ts @@ -1,4 +1,121 @@ import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'ai' }); + +const ctx = (secrets: Record = { CHUTES_API_KEY: 'test-key' }, dryRun = false) => ({ + secret: (key: string) => secrets[key], + log: () => {}, + dryRun, +}); + +describe('Chutes OpenAI-compatible generation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('short-circuits dry-run before network calls', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate(ctx({ CHUTES_API_KEY: 'test-key' }, true), 'hello', {}, {}); + + expect(result).toEqual({ text: '[dry-run]', model: 'deepseek-ai/DeepSeek-V3.2-TEE' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts chat completions requests and maps usage tokens', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'hi from chutes' } }], + model: 'moonshotai/Kimi-K2.5-TEE', + usage: { prompt_tokens: 7, completion_tokens: 3 }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate(ctx(), 'hello', { + model: 'moonshotai/Kimi-K2.5-TEE', + system: 'be brief', + maxTokens: 20, + temperature: 0.2, + extra: { top_p: 0.9 }, + }, {}); + + expect(fetchMock).toHaveBeenCalledOnce(); + const call = fetchMock.mock.calls[0]; + expect(call).toBeDefined(); + const [url, request] = call!; + expect(url).toBe('https://llm.chutes.ai/v1/chat/completions'); + expect(request.headers.authorization).toBe('Bearer test-key'); + expect(JSON.parse(request.body)).toEqual({ + model: 'moonshotai/Kimi-K2.5-TEE', + messages: [ + { role: 'system', content: 'be brief' }, + { role: 'user', content: 'hello' }, + ], + max_tokens: 20, + temperature: 0.2, + top_p: 0.9, + }); + expect(result).toEqual({ + text: 'hi from chutes', + model: 'moonshotai/Kimi-K2.5-TEE', + inputTokens: 7, + outputTokens: 3, + }); + }); + + it('normalizes configured base URLs with trailing slashes', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }], model: 'deepseek-ai/DeepSeek-V3.2-TEE' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await adapter.generate(ctx(), 'hello', {}, { baseUrl: 'https://proxy.example.com/' }); + + const [url] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://proxy.example.com/v1/chat/completions'); + }); + + it.each([ + ['missing scheme', 'proxy.example.com'], + ['unsupported scheme', 'ftp://proxy.example.com'], + ['credentials', 'https://user:pass@proxy.example.com'], + ['query string', 'https://proxy.example.com?debug=true'], + ['fragment', 'https://proxy.example.com#v1'], + ])('rejects unclean configured base URLs: %s', async (_case, baseUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.generate(ctx(), 'hello', {}, { baseUrl })).rejects.toThrow( + /Chutes baseUrl/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('includes status and redacted response body excerpt on errors', async () => { + const apiKey = 'test-key-crossing-truncation-boundary'; + const prefix = 'x'.repeat(190); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => `${prefix}${apiKey} rate limited`, + })); + + let error: unknown; + try { + await adapter.generate(ctx({ CHUTES_API_KEY: apiKey }), 'hello', {}, {}); + } catch (exc) { + error = exc; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('Chutes 429:'); + expect((error as Error).message).toContain('[redacted]'); + expect((error as Error).message).not.toContain(apiKey); + expect((error as Error).message).not.toContain(apiKey.slice(0, 10)); + }); +}); diff --git a/packages/ai/chutes/src/index.ts b/packages/ai/chutes/src/index.ts index f17fce51..634bc134 100644 --- a/packages/ai/chutes/src/index.ts +++ b/packages/ai/chutes/src/index.ts @@ -4,25 +4,88 @@ interface Config { baseUrl?: string; } +const DEFAULT_BASE = 'https://llm.chutes.ai'; + +function chatCompletionsUrl(baseUrl?: string): string { + return `${cleanBaseUrl(baseUrl ?? DEFAULT_BASE)}/v1/chat/completions`; +} + +function cleanBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Chutes baseUrl must be a valid URL'); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Chutes baseUrl must use http or https'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('Chutes baseUrl must be a clean API base without credentials, query, or hash'); + } + return url.toString().replace(/\/+$/, ''); +} + +function redact(value: string, apiKey: string): string { + return apiKey ? value.split(apiKey).join('[redacted]') : value; +} + export default defineAi({ id: 'ai-chutes', label: 'Chutes', - defaultModel: 'CHUTES_API_KEY', - models: ['CHUTES_API_KEY'], - - async generate(ctx, prompt, _opts, _config) { - const apiKey = ctx.secret('https://chutes.ai'); - if (!apiKey) throw new Error('https://chutes.ai not in vault — run `sh1pt promote ai setup`'); - ctx.log(`[stub] ai-chutes · ${prompt.length} chars in — integration pending`); - return { text: '[stub — ai-chutes integration not yet implemented]', model: 'CHUTES_API_KEY' }; + defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE', + models: [ + 'deepseek-ai/DeepSeek-V3.2-TEE', + 'moonshotai/Kimi-K2.5-TEE', + 'zai-org/GLM-5.2-TEE', + 'Qwen/Qwen3-32B-TEE', + ], + + async generate(ctx, prompt, opts, config) { + const apiKey = ctx.secret('CHUTES_API_KEY'); + if (!apiKey) throw new Error('CHUTES_API_KEY not in vault'); + const model = opts.model ?? 'deepseek-ai/DeepSeek-V3.2-TEE'; + ctx.log(`chutes · model=${model} · ${prompt.length} chars in`); + if (ctx.dryRun) return { text: '[dry-run]', model }; + + const messages: Array<{ role: string; content: string }> = []; + if (opts.system) messages.push({ role: 'system', content: opts.system }); + messages.push({ role: 'user', content: prompt }); + + const res = await fetch(chatCompletionsUrl(config.baseUrl), { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model, + messages, + ...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}), + ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}), + ...opts.extra, + }), + }); + if (!res.ok) throw new Error(`Chutes ${res.status}: ${redact(await res.text(), apiKey).slice(0, 200)}`); + const data = (await res.json()) as { + choices: Array<{ message?: { content?: string } }>; + model: string; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + return { + text: data.choices[0]?.message?.content ?? '', + model: data.model, + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + }; }, setup: tokenSetup({ - secretKey: 'https://chutes.ai', + secretKey: 'CHUTES_API_KEY', label: 'Chutes', - vendorDocUrl: '', + vendorDocUrl: 'https://chutes.ai/app', steps: [ - 'Sign in at and create an API key', + 'Sign in at https://chutes.ai/app and create an API key', 'Copy the key — usually shown once', 'Paste below; sh1pt encrypts it in the vault', ],