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)
182 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|