gadget/gadget-code/src/web-cli.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

657 lines
17 KiB
TypeScript

// src/web-cli.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import env from "./config/env.js";
const aiEnv: IAiEnvironment = {
NODE_ENV: env.NODE_ENV || "develop",
services: {
google: {
cse: {
apiKey: env.google.cse.apiKey,
engineId: env.google.cse.engineId,
},
},
},
};
import { v4 as uuidv4 } from "uuid";
import "./lib/db.js";
/*
* Models
*/
import ApiClient, { ApiClientStatus, IApiClient } from "./models/api-client.js";
import User from "./models/user.js";
import AiProvider from "./models/ai-provider.js";
/*
* Services
*/
import {
startServices,
stopServices,
ApiClientService,
UserService,
CryptoService,
} from "./services/index.js";
/*
* App Logic
*/
import { createAiApi, IAiEnvironment, type IAiLogger } from "@gadget/ai";
import {
IUser,
type IAiModel,
type IAiModelCapabilities,
type IAiModelSettings,
} from "@gadget/api";
import { DtpProcess } from "./lib/process.js";
class DtpWebCli extends DtpProcess {
get name(): string {
return "DtpWebCli";
}
get slug(): string {
return "dtp-web-cli";
}
constructor() {
super();
}
async run(argv: string[]): Promise<void> {
const cmd = argv.shift();
if (!cmd) {
throw new Error("must specify command");
}
switch (cmd) {
case "admin":
return this.onAdminCmd(argv);
case "api-client":
return this.onApiClientCmd(argv);
case "user":
return this.onUserCmd(argv);
case "provider":
return this.onProviderCmd(argv);
default:
break;
}
throw new Error(`unknown command: ${cmd}`);
}
async onAdminCmd(argv: string[]): Promise<void> {
const action = argv.shift();
if (!action) {
throw new Error("must specify user command action");
}
switch (action) {
case "grant":
return this.onAdminGrant(argv);
case "revoke":
return this.onAdminRevoke(argv);
default:
break;
}
throw new Error(`unknown user command action: ${action}`);
}
async onAdminGrant(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email for admin grant");
}
const user = await User.findOne({
email_lc: email.trim().toLowerCase(),
});
if (!user) {
throw new Error(`user ${email} not found`);
}
user.flags.isAdmin = true;
await user.save();
this.log.info(`admin rights granted to ${email}`);
}
async onAdminRevoke(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email for admin revoke");
}
const user = await User.findOne({
email_lc: email.trim().toLowerCase(),
});
if (!user) {
throw new Error(`user ${email} not found`);
}
user.flags.isAdmin = false;
await user.save();
this.log.info(`admin rights revoked from ${email}`);
}
async onApiClientCmd(argv: string[]): Promise<void> {
const action = argv.shift();
if (!action) {
throw new Error("must specify api client command action");
}
switch (action) {
case "add":
return this.onApiClientAdd(argv);
case "ls":
return this.onApiClientList(argv);
case "status":
return this.onApiClientSetStatus(argv);
case "remove":
return this.onApiClientRemove(argv);
default:
break;
}
throw new Error(`unknown api client command action: ${action}`);
}
async onApiClientAdd(argv: string[]): Promise<void> {
const name = argv.shift();
const description = argv.shift();
const email = argv.shift();
let user;
if (email) {
user = await UserService.getByEmail(email);
if (!user) {
throw new Error("user not found");
}
}
const definition: Partial<IApiClient> = {
name,
description,
};
if (user) {
definition.user = user._id;
}
const client = await ApiClientService.create(definition);
this.printApiClientList([client]);
}
async onApiClientList(_argv: string[]): Promise<void> {
const clients: IApiClient[] = await ApiClient.find({
status: ApiClientStatus.Active,
})
.sort({ name: 1 })
.populate([{ path: "user", select: "-passwordSalt -password" }])
.lean();
this.printApiClientList(clients);
}
printApiClientList(clients: IApiClient[]) {
console.log(
"Name".padEnd(20),
"Gadget Key".padEnd(21),
"Secret".padEnd(36),
"User ID".padEnd(21),
"Email",
);
console.log(
"--------------------------------------------------------------------------------",
);
for (const client of clients) {
let log = `${client.name.padEnd(20)} ${client._id.toString()} ${client.secret}`;
if (client.user) {
const user = client.user as IUser;
log += ` ${user._id} ${user.email}`;
}
console.log(log);
}
}
async onApiClientSetStatus(argv: string[]): Promise<void> {
const clientId = argv.shift();
if (!clientId) {
throw new Error("client ID is required");
}
const client = await ApiClientService.getById(clientId);
if (!client) {
throw new Error("Client not found");
}
const status = argv.shift() as ApiClientStatus;
if (!status) {
throw new Error("New client status is required");
}
await ApiClientService.setStatus(client, status);
this.log.info("API client status updated", { _id: clientId, status });
}
async onApiClientRemove(argv: string[]): Promise<void> {
const clientId = argv.shift();
if (!clientId) {
throw new Error("client ID is required");
}
const client = await ApiClientService.getById(clientId);
if (!client) {
throw new Error("Client not found");
}
await ApiClientService.remove(client);
this.log.info("API client removed", { _id: clientId });
}
async onUserCmd(argv: string[]): Promise<void> {
const action = argv.shift();
if (!action) {
throw new Error("must specify user command action");
}
switch (action) {
case "add":
return this.onUserAdd(argv);
case "view":
return this.onUserView(argv);
case "password":
return this.onUserPassword(argv);
case "remove":
return this.onUserRemove(argv);
case "ban":
return this.onUserBan(argv);
default:
break;
}
throw new Error(`unknown user command action: ${action}`);
}
async onUserAdd(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
const password = argv.shift();
if (!password) {
throw new Error("must specify password");
}
const displayName: string | undefined = argv.shift();
const user = await UserService.create(email, password, displayName);
this.log.info(`user created: id:${user._id}, email:${user.email}`);
}
async onUserView(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
const user = await UserService.getByEmail(email);
if (!user) {
throw new Error("user not found");
}
this.log.info("user account", user);
}
async onUserRemove(argv: string[]): Promise<void> {
let email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
email = email.trim().toLowerCase();
const user = await User.findOne({ email }).lean();
if (!user) {
throw new Error(`user not found: ${email}`);
}
await User.deleteOne({ _id: user._id });
this.log.info(`user ${email} removed`);
}
async onUserBan(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
const email_lc = email.trim().toLowerCase();
const user = await User.findOne({ email_lc }).lean();
if (!user) {
throw new Error(`user not found: ${email}`);
}
await User.updateOne(
{ _id: user._id },
{
$set: {
"flags.isBanned": true,
},
},
);
this.log.info(`user ${email} banned`);
}
async onUserUnban(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
const email_lc = email.trim().toLowerCase();
const user = await User.findOne({ email_lc }).lean();
if (!user) {
throw new Error(`user not found: ${email}`);
}
await User.updateOne(
{ _id: user._id },
{
$set: {
"flags.isBanned": false,
},
},
);
this.log.info(`user ${email} unbanned`);
}
async onUserPassword(argv: string[]): Promise<void> {
let email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
const password = argv.shift();
if (!password) {
throw new Error("must specify password");
}
const email_lc = email.trim().toLowerCase();
const user = await User.findOne({ email_lc });
if (!user) {
throw new Error(`user not found: ${email}`);
}
user.passwordSalt = uuidv4();
user.password = await CryptoService.maskPassword(
user.passwordSalt,
password,
);
await user.save();
this.log.info(`user ${email} password changed`);
}
async onProviderCmd(argv: string[]): Promise<void> {
const action = argv.shift();
if (!action) {
throw new Error("must specify provider command action");
}
switch (action) {
case "add":
return this.onProviderAdd(argv);
case "ls":
return this.onProviderList(argv);
case "status":
return this.onProviderSetStatus(argv);
case "remove":
return this.onProviderRemove(argv);
case "probe":
return this.onProviderProbe(argv);
default:
break;
}
throw new Error(`unknown provider command action: ${action}`);
}
async onProviderAdd(argv: string[]): Promise<void> {
const name = argv.shift();
if (!name) {
throw new Error("must specify provider name");
}
const sdkType = argv.shift();
if (!sdkType) {
throw new Error("must specify SDK type (ollama or openai)");
}
if (sdkType !== "ollama" && sdkType !== "openai") {
throw new Error("SDK type must be 'ollama' or 'openai'");
}
const baseUrl = argv.shift();
if (!baseUrl) {
throw new Error("must specify base URL");
}
const apiKey = argv.shift();
if (sdkType === "openai" && !apiKey) {
throw new Error("API key required for OpenAI providers");
}
// Check if provider with this name already exists
const existing = await AiProvider.findOne({ baseUrl });
if (existing) {
throw new Error(`provider with name '${baseUrl}' already exists`);
}
const provider = new AiProvider({
name,
apiType: sdkType,
baseUrl,
apiKey: apiKey || "",
enabled: true,
models: [],
lastModelRefresh: new Date(),
});
await provider.save();
this.log.info("provider added", {
_id: provider._id,
name: provider.name,
apiType: provider.apiType,
baseUrl: provider.baseUrl,
});
// Auto-probe for models
this.log.info("probing provider for models...");
await this.onProviderProbe([provider._id]);
}
async onProviderList(_argv: string[]): Promise<void> {
const providers = await AiProvider.find({}).sort({ name: 1 }).lean();
console.log(
"Name".padEnd(20),
"ID".padEnd(24),
"Type".padEnd(8),
"URL".padEnd(30),
"Models",
"Enabled",
);
console.log(
"------------------------------------------------------------------------------------------------------------",
);
for (const provider of providers) {
console.log(
provider.name.padEnd(20),
provider._id.toString().padEnd(24),
provider.apiType.padEnd(8),
provider.baseUrl.padEnd(30),
String(provider.models.length).padEnd(6),
provider.enabled ? "Yes" : "No",
);
}
}
async onProviderSetStatus(argv: string[]): Promise<void> {
const providerId = argv.shift();
if (!providerId) {
throw new Error("provider ID is required");
}
const provider = await AiProvider.findById(providerId);
if (!provider) {
throw new Error("Provider not found");
}
const status = argv.shift();
if (!status) {
throw new Error("New provider status is required (active or inactive)");
}
if (status !== "active" && status !== "inactive") {
throw new Error("Status must be 'active' or 'inactive'");
}
provider.enabled = status === "active";
await provider.save();
this.log.info("Provider status updated", {
_id: providerId,
enabled: provider.enabled,
});
}
async onProviderRemove(argv: string[]): Promise<void> {
const providerId = argv.shift();
if (!providerId) {
throw new Error("provider ID is required");
}
const provider = await AiProvider.findById(providerId).select("+apiKey");
if (!provider) {
throw new Error("Provider not found");
}
this.log.info("Provider removed", {
_id: providerId,
name: provider.name,
});
}
async onProviderProbe(argv: string[]): Promise<void> {
const providerId = argv.shift();
if (!providerId) {
throw new Error("provider ID is required");
}
const provider = await AiProvider.findById(providerId).select("+apiKey");
if (!provider) {
throw new Error("Provider not found");
}
this.log.info("probing provider for models", {
name: provider.name,
apiType: provider.apiType,
});
const cliLogger: IAiLogger = {
debug: async (msg, meta) => this.log.debug(msg, meta),
info: async (msg, meta) => this.log.info(msg, meta),
warn: async (msg, meta) => this.log.warn(msg, meta),
error: async (msg, meta) => this.log.error(msg, meta),
};
const api = createAiApi(
aiEnv,
{
_id: provider._id,
name: provider.name,
sdk: provider.apiType,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
},
cliLogger,
);
this.log.info("fetching model list from provider...");
const listResult = await api.listModels();
this.log.info(`found ${listResult.models.length} models`, {
count: listResult.models.length,
});
const models: IAiModel[] = [];
for (const modelInfo of listResult.models) {
this.log.info(`probing model: ${modelInfo.id}`);
try {
const probeResult = await api.probeModel(modelInfo.id);
// Compute provider-specific inference settings
const settings: IAiModelSettings = {
...(probeResult.settings as IAiModelSettings | undefined),
};
if (provider.apiType === 'ollama') {
// Ollama: always override numPredict to -1 (unlimited) for agentic workflows
// The model must generate until its natural stop token or context limit
settings.numPredict = -1;
// numCtx is already populated by probeResult.settings from extractSettings()
} else if (provider.apiType === 'openai') {
// OpenAI-compatible: set maxCompletionTokens to model's context window
// This prevents compatible providers (Gab AI, etc.) from imposing low defaults
settings.maxCompletionTokens = modelInfo.contextWindow || 16384;
}
const model: IAiModel = {
id: modelInfo.id,
name: modelInfo.name,
parameterCount: modelInfo.parameterCount,
parameterLabel: modelInfo.parameterLabel,
contextWindow: modelInfo.contextWindow,
capabilities: probeResult.capabilities as IAiModelCapabilities,
settings,
};
models.push(model);
this.log.info(`model probed successfully`, {
model: model.id,
capabilities: model.capabilities,
});
} catch (error) {
this.log.error(`failed to probe model ${modelInfo.id}`, {
error: (error as Error).message,
});
}
}
provider.models = models;
provider.lastModelRefresh = new Date();
await provider.save();
this.log.info(`model discovery complete`, {
totalModels: models.length,
models: models.map((m) => ({
id: m.id,
name: m.name,
capabilities: m.capabilities,
})),
});
}
}
(async () => {
try {
await startServices();
const cli = new DtpWebCli();
await cli.run(process.argv.slice(2));
await stopServices();
process.exit(0);
} catch (error) {
console.error((error as Error).message);
process.exit(-1);
}
})();