gadget/packages/ai/src/openai.test.ts
Rob Colbert 07a760c7b5 feat: add numPredict, numCtx, maxCompletionTokens to model config pipeline
Fixes premature AI API response truncation by propagating inference
parameters through the entire probe → storage → runtime → API call chain.

Root cause: Ollama defaults num_predict to 128 tokens and num_ctx to
4096, silently truncating output and context. We never overrode these.

Changes:
- IAiModelSettings: add numPredict, maxCompletionTokens fields
- IDroneModelConfig: moved from gadget-drone to @gadget/api (shared),
  expanded with numPredict, numCtx, maxCompletionTokens params
- IAiModelConfig.params: add numPredict, numCtx, maxCompletionTokens
- IAiModelProbeResult.settings: add numPredict, maxCompletionTokens
- AiModelSettingsSchema (Mongoose): add numPredict, maxCompletionTokens
- Ollama extractSettings(): extract num_predict from model parameters
- Ollama generate()/chat(): pass options: { num_ctx, num_predict }
- OpenAI all three create() calls: add max_completion_tokens
- web-cli.ts onProviderProbe(): compute numPredict (-1 for Ollama)
  and maxCompletionTokens (contextWindow for OpenAI) during probe
- agent.ts main + subagent loops: read model settings from provider
  cached models, build IDroneModelConfig with stored params
- ai.ts: remove local IDroneModelConfig, import from @gadget/api
- chat-session.ts: add new params to title generation call
- Tests: update all fixtures with new params, all 19 tests pass

Defaults when model settings unavailable:
- numPredict: -1 (Ollama unlimited - generate until natural stop)
- numCtx: 131072 (128k - covers most modern models)
- maxCompletionTokens: 16384 (16k - reasonable OpenAI default)
2026-05-11 13:50:19 -04:00

182 lines
5.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCreate = vi.fn();
const mockList = vi.fn();
const mockRetrieve = vi.fn();
vi.mock("openai", () => {
return {
default: class MockOpenAI {
chat = {
completions: {
create: mockCreate,
},
};
models = {
list: mockList,
retrieve: mockRetrieve,
};
},
};
});
import { OpenAiApi } from "./openai";
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const mockEnv = { NODE_ENV: "test", services: {} };
const mockProvider = {
_id: "provider-openai",
name: "OpenAI Compatible",
sdk: "openai" as const,
baseUrl: "https://example.test/v1",
apiKey: "test-key",
};
async function* streamChunks(chunks: unknown[]) {
for (const chunk of chunks) {
yield chunk;
}
}
describe("OpenAiApi", () => {
let api: OpenAiApi;
beforeEach(() => {
vi.clearAllMocks();
api = new OpenAiApi(mockEnv as any, mockProvider as any, mockLogger as any);
});
it("rejects an empty streaming and fallback response", async () => {
mockCreate
.mockResolvedValueOnce(streamChunks([
{ choices: [{ delta: {}, finish_reason: "stop" }] },
]))
.mockResolvedValueOnce({
choices: [{ message: { content: "", tool_calls: [] }, finish_reason: "stop" }],
});
await expect(api.chat(
{
provider: mockProvider as any,
modelId: "test-model",
params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40, numPredict: -1, numCtx: 131072, maxCompletionTokens: 16384 },
},
{ userPrompt: "Hello", context: [], tools: [] },
vi.fn(),
)).rejects.toThrow("Provider returned an empty chat response");
});
it("assembles streamed tool-call argument fragments and returns them", async () => {
mockCreate.mockResolvedValueOnce(streamChunks([
{
choices: [{
delta: {
tool_calls: [{
index: 0,
id: "call_1",
type: "function",
function: { name: "file_read", arguments: '{"path"' },
}],
},
finish_reason: null,
}],
},
{
choices: [{
delta: {
tool_calls: [{
index: 0,
function: { arguments: ':"index.html"}' },
}],
},
finish_reason: "tool_calls",
}],
},
]));
const streamCallback = vi.fn();
const response = await api.chat(
{
provider: mockProvider as any,
modelId: "test-model",
params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40, numPredict: -1, numCtx: 131072, maxCompletionTokens: 16384 },
},
{ userPrompt: "Read index.html", context: [], tools: [] },
streamCallback,
);
// Tool calls are returned, not executed
expect(response.toolCalls).toBeDefined();
expect(response.toolCalls!.length).toBe(1);
expect(response.toolCalls![0].function.name).toBe("file_read");
expect(response.toolCalls![0].function.arguments).toBe('{"path":"index.html"}');
expect(mockCreate).toHaveBeenCalledTimes(1);
});
it("falls back to non-streaming response when stream has no deltas", async () => {
mockCreate
.mockResolvedValueOnce(streamChunks([
{ choices: [{ delta: {}, finish_reason: "stop" }] },
]))
.mockResolvedValueOnce({
choices: [{ message: { content: "Fallback answer", tool_calls: [] }, finish_reason: "stop" }],
});
const streamCallback = vi.fn();
const response = await api.chat(
{
provider: mockProvider as any,
modelId: "test-model",
params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40, numPredict: -1, numCtx: 131072, maxCompletionTokens: 16384 },
},
{ userPrompt: "Hello", context: [], tools: [] },
streamCallback,
);
expect(response.response).toBe("Fallback answer");
expect(streamCallback).toHaveBeenCalledWith({ type: "response", data: "Fallback answer" });
});
it("returns conversational responses without forcing another iteration", async () => {
mockCreate.mockResolvedValueOnce(streamChunks([
{ choices: [{ delta: { content: "I can talk this through." }, finish_reason: "stop" }] },
]));
const streamCallback = vi.fn();
const response = await api.chat(
{
provider: mockProvider as any,
modelId: "test-model",
params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40, numPredict: -1, numCtx: 131072, maxCompletionTokens: 16384 },
},
{
userPrompt: "Don't edit code. Just talk to me.",
context: [],
tools: [{
name: "file_edit",
category: "file",
definition: {
type: "function" as const,
function: {
name: "file_edit",
description: "Edit file",
parameters: { type: "object", properties: {} },
},
},
execute: vi.fn(),
}],
},
streamCallback,
);
expect(response.response).toBe("I can talk this through.");
expect(mockCreate).toHaveBeenCalledTimes(1);
});
});