chore(lint): reduce warning debt across core adapters and model clients

This commit is contained in:
William Valentin
2026-02-15 23:03:42 -08:00
parent 92da407e22
commit 49b752e8b0
17 changed files with 239 additions and 117 deletions
+14 -6
View File
@@ -1,4 +1,4 @@
import type { ModelClient, Message, ChatRequest, ChatResponse, ModelToolCall, TokenUsage } from '../../models/types.js';
import type { ModelClient, Message, ChatRequest, ChatResponse, TokenUsage } from '../../models/types.js';
import type { ModelRouter, ModelTier } from '../../models/router.js';
import type { Session } from '../../session/index.js';
import type { ToolRegistry } from '../../tools/registry.js';
@@ -8,7 +8,7 @@ import type { ToolPolicyContext } from '../../tools/policy.js';
import { auditLogger } from '../../audit/index.js';
import type { Attachment } from '../../channels/types.js';
import type { OutboundAttachmentCollector } from './attachments.js';
import { buildUserMessage, getMessageText } from '../../models/media.js';
import { buildUserMessage } from '../../models/media.js';
export interface ToolUseEvent {
type: 'start' | 'end';
@@ -142,7 +142,12 @@ export class NativeAgent {
}
private async toolLoop(): Promise<string> {
const tools = this.toolRegistry!.filteredToAnthropicFormat(this._toolPolicyContext);
const toolRegistry = this.toolRegistry;
const toolExecutor = this.toolExecutor;
if (!toolRegistry || !toolExecutor) {
throw new Error('Tool loop requires tool registry and executor');
}
const tools = toolRegistry.filteredToAnthropicFormat(this._toolPolicyContext);
// Track whether untrusted content (web/fetched/tool output) has been introduced
// during this run. Used to harden against prompt injection.
@@ -218,7 +223,10 @@ export class NativeAgent {
}
// Safe to assert non-null — wantsToolUse guarantees toolCalls exists and is non-empty
const toolCalls = response.toolCalls!;
const toolCalls = response.toolCalls;
if (!toolCalls || toolCalls.length === 0) {
continue;
}
// Check for repeated tool calls — build a fingerprint from tool names + args
const fingerprint = toolCalls
@@ -264,7 +272,7 @@ export class NativeAgent {
for (const tc of toolCalls) {
this.throwIfCancelled();
const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name;
const internalName = toolRegistry.getByApiName(tc.name)?.name ?? tc.name;
this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args });
let elevationUntilMs: number | undefined;
@@ -311,7 +319,7 @@ export class NativeAgent {
}
: undefined;
const result = await this.toolExecutor!.execute(internalName, tc.args, perCallContext);
const result = await toolExecutor.execute(internalName, tc.args, perCallContext);
this.onToolUse?.({ type: 'end', tool: internalName, result });
+13 -11
View File
@@ -5,11 +5,13 @@ import type { ChatResponse, ModelClient } from '../../models/types.js';
import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
import { HookEngine } from '../../hooks/engine.js';
import { MemoryStore } from '../../memory/store.js';
import type { Session } from '../../session/index.js';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { auditLogger, initAuditLogger } from '../../audit/index.js';
import type { AuditLogger } from '../../audit/index.js';
import type { Message } from '../../models/types.js';
describe('AgentOrchestrator', () => {
let mockDefaultClient: ModelClient;
@@ -468,7 +470,7 @@ describe('AgentOrchestrator', () => {
fallbackChain: [],
});
const history: any[] = [
const history: Message[] = [
{ role: 'user', content: 'u1' },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'u2' },
@@ -476,19 +478,19 @@ describe('AgentOrchestrator', () => {
{ role: 'user', content: 'u3' },
{ role: 'assistant', content: 'a3' },
];
const session = {
const session: Session = {
id: 'session-compact-audit',
addMessage: vi.fn((m: any) => { history.push(m); }),
addMessage: vi.fn((m: Message) => { history.push(m); }),
getHistory: vi.fn(() => [...history]),
clear: vi.fn(() => { history.length = 0; }),
replaceHistory: vi.fn((msgs: any[]) => {
replaceHistory: vi.fn((msgs: Message[]) => {
history.length = 0;
history.push(...msgs);
}),
getConfig: vi.fn(() => undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
} as any;
};
const sessionCompact = vi.fn();
const previousAuditLogger = auditLogger;
@@ -717,27 +719,27 @@ describe('AgentOrchestrator', () => {
});
// Minimal Session stub that supports rollback via replaceHistory().
const history: any[] = [];
const session = {
const history: Message[] = [];
const session: Session = {
id: 'test',
addMessage: vi.fn((m: any) => { history.push(m); }),
addMessage: vi.fn((m: Message) => { history.push(m); }),
getHistory: vi.fn(() => [...history]),
clear: vi.fn(() => { history.length = 0; }),
replaceHistory: vi.fn((msgs: any[]) => {
replaceHistory: vi.fn((msgs: Message[]) => {
history.length = 0;
history.push(...msgs);
}),
getConfig: vi.fn(() => undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
} as any;
};
const registry = new ToolRegistry();
registry.register({
name: 'test.echo',
description: 'echo',
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
execute: async (args: any) => ({ success: true, output: String(args.text ?? '') }),
execute: async (args: { text?: string }) => ({ success: true, output: String(args.text ?? '') }),
});
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
+13 -10
View File
@@ -85,7 +85,7 @@ export class DiscordAdapter implements ChannelAdapter {
async connect(): Promise<void> {
this._status = 'connecting';
this.client = new Client({
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
@@ -93,23 +93,24 @@ export class DiscordAdapter implements ChannelAdapter {
GatewayIntentBits.DirectMessages,
],
});
this.client = client;
// ── Ready handler — resolve connect() when the bot is online ──
const readyPromise = new Promise<void>((resolve) => {
this.client!.on(Events.ClientReady, () => {
console.log(`Discord bot ready as ${this.client!.user?.tag}`);
client.on(Events.ClientReady, () => {
console.log(`Discord bot ready as ${client.user?.tag}`);
this._status = 'connected';
resolve();
});
});
// ── Message handler — route inbound messages ──
this.client.on(Events.MessageCreate, (message: DiscordMessage) => {
client.on(Events.MessageCreate, (message: DiscordMessage) => {
void this.handleMessage(message);
});
// Log in and wait for the ready event
await this.client.login(this.config.botToken);
await client.login(this.config.botToken);
await readyPromise;
}
@@ -162,8 +163,11 @@ export class DiscordAdapter implements ChannelAdapter {
name: attachment.filename ?? 'attachment',
});
}
if (!attachment.url) {
throw new Error('Attachment must include data or url');
}
// URL-based attachment
return new AttachmentBuilder(attachment.url!, {
return new AttachmentBuilder(attachment.url, {
name: attachment.filename ?? 'attachment',
});
}
@@ -180,9 +184,8 @@ export class DiscordAdapter implements ChannelAdapter {
// ── Guild/channel filtering ──
if (!isDM) {
// Check allowed guild IDs
if (
!isAllowedByAllowlist(message.guild!.id, this.config.allowedGuildIds)
) {
const guildId = message.guild?.id;
if (!guildId || !isAllowedByAllowlist(guildId, this.config.allowedGuildIds)) {
return;
}
@@ -223,7 +226,7 @@ export class DiscordAdapter implements ChannelAdapter {
// Send typing indicator (lasts 10 seconds, no need for interval)
try {
if ('sendTyping' in message.channel) {
(message.channel as any).sendTyping();
await (message.channel as { sendTyping: () => Promise<unknown> }).sendTyping();
}
} catch { /* ignore typing errors */ }
+21 -10
View File
@@ -57,6 +57,10 @@ interface WhatsAppMessage {
type?: string;
/** Download the media attached to this message. */
downloadMedia?: () => Promise<{ mimetype: string; data: string; filename?: string } | null>;
/** Chat handle for typing indicator. */
getChat?: () => Promise<{ sendStateTyping: () => Promise<void> }>;
/** Mentioned user IDs in message metadata. */
mentionedIds?: string[];
}
/**
@@ -110,34 +114,41 @@ export class WhatsAppAdapter implements ChannelAdapter {
args: puppeteerArgs,
},
});
const client = this.client;
if (!client) {
throw new Error('WhatsApp client initialization failed');
}
// Promise that resolves on 'ready' or rejects on 'auth_failure'
const readyPromise = new Promise<void>((resolve, reject) => {
this.client!.on('ready', () => {
client.on('ready', () => {
console.log('WhatsApp bot connected');
this._status = 'connected';
// Capture bot's own JID for mention detection
this.botId = (this.client as any)?.info?.wid?._serialized;
const clientInfo = client as InstanceType<typeof Client> & {
info?: { wid?: { _serialized?: string } };
};
this.botId = clientInfo.info?.wid?._serialized;
resolve();
});
this.client!.on('auth_failure', (msg: string) => {
client.on('auth_failure', (msg: string) => {
this._status = 'error';
reject(new Error(`WhatsApp auth failure: ${msg}`));
});
this.client!.on('qr', (qr: string) => {
client.on('qr', (qr: string) => {
console.log('WhatsApp QR code received. Scan with your phone:');
console.log(qr);
});
});
// Register message event handler
this.client.on('message', (message: unknown) => {
client.on('message', (message: unknown) => {
this.handleMessage(message as WhatsAppMessage);
});
await this.client.initialize();
await client.initialize();
await readyPromise;
} catch (error) {
this._status = 'error';
@@ -234,7 +245,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
requireMention: this.config.requireMention,
defaultRequireMention: true,
mentionsBot: message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) ||
(message as unknown as { mentionedIds?: string[] }).mentionedIds?.some((id) => id === this.botId) === true,
message.mentionedIds?.some((id) => id === this.botId) === true,
})) {
// WhatsApp mentions use @phone_number format in body
// Also check for mentions in the message mentionedIds
@@ -269,8 +280,8 @@ export class WhatsAppAdapter implements ChannelAdapter {
// Send typing indicator
try {
const chat = await (message as any).getChat();
await chat.sendStateTyping();
const chat = await message.getChat?.();
await chat?.sendStateTyping();
} catch { /* ignore typing errors */ }
// Strip bot mention from message body for group messages
@@ -286,7 +297,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
const attachments: Attachment[] = [];
if (message.hasMedia) {
try {
const media = await (message as any).downloadMedia();
const media = await message.downloadMedia?.();
if (media && typeof media.mimetype === 'string') {
const mimeType = media.mimetype;
const isAudio = mimeType.startsWith('audio/');
+20 -15
View File
@@ -20,6 +20,11 @@ export interface DoctorContext {
}
type Check = (ctx: DoctorContext) => Promise<CheckResult>;
type UnknownRecord = Record<string, unknown>;
const asRecord = (value: unknown): UnknownRecord | undefined => (
value && typeof value === 'object' ? value as UnknownRecord : undefined
);
const checkConfigExists: Check = async (ctx) => {
if (existsSync(ctx.configPath)) {
@@ -78,8 +83,9 @@ const checkDeprecatedConfigKeys: Check = async (ctx) => {
try {
const raw = readFileSync(ctx.configPath, 'utf-8');
const parsed = parse(raw) as any;
const tailscaleOnly = Boolean(parsed?.server && typeof parsed.server === 'object' && 'tailscale_only' in parsed.server);
const parsed = asRecord(parse(raw));
const server = asRecord(parsed?.server);
const tailscaleOnly = Boolean(server && 'tailscale_only' in server);
if (tailscaleOnly) {
return {
@@ -198,11 +204,11 @@ const checkModelConnectivity: Check = async (ctx) => {
return true;
}
const oauth = o.oauth;
const oauthRecord = asRecord(oauth);
return Boolean(
oauth
&& typeof oauth === 'object'
&& typeof (oauth as any).access_token === 'string'
&& typeof (oauth as any).refresh_token === 'string',
oauthRecord
&& typeof oauthRecord.access_token === 'string'
&& typeof oauthRecord.refresh_token === 'string',
);
};
@@ -213,22 +219,21 @@ const checkModelConnectivity: Check = async (ctx) => {
}
const o = openai as Record<string, unknown>;
const apiKey = o.api_key;
const apiKeyRecord = asRecord(apiKey);
return Boolean(
apiKey
&& typeof apiKey === 'object'
&& typeof (apiKey as any).api_key === 'string'
&& (apiKey as any).api_key.length > 0,
typeof apiKeyRecord?.api_key === 'string'
&& apiKeyRecord.api_key.length > 0,
);
};
const storeAnthropicApiKeyPresent = (): boolean => {
const anthropic = store.anthropic as any;
return Boolean(anthropic && typeof anthropic.api_key === 'string' && anthropic.api_key.length > 0);
const anthropic = asRecord(store.anthropic);
return Boolean(typeof anthropic?.api_key === 'string' && anthropic.api_key.length > 0);
};
const storeAnthropicAuthTokenPresent = (): boolean => {
const anthropic = store.anthropic as any;
return Boolean(anthropic && typeof anthropic.auth_token === 'string' && anthropic.auth_token.length > 0);
const anthropic = asRecord(store.anthropic);
return Boolean(typeof anthropic?.auth_token === 'string' && anthropic.auth_token.length > 0);
};
const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => {
@@ -318,7 +323,7 @@ const checkModelConnectivity: Check = async (ctx) => {
const envVar = envVarMap[provider];
const sources = {
config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0,
env: Boolean(envVar && process.env[envVar] && process.env[envVar]!.length > 0),
env: Boolean(envVar && typeof process.env[envVar] === 'string' && process.env[envVar].length > 0),
store: false,
};
const ok = sources.config || sources.env;
+3 -5
View File
@@ -1,16 +1,14 @@
import { describe, it, expect } from 'vitest';
import { createInterface } from 'readline/promises';
import { EventEmitter } from 'events';
import type { Interface as ReadlineInterface } from 'readline/promises';
import { createPrompter } from './prompts.js';
import { ConfigBuilder } from './config.js';
import { setupProviders } from './providers.js';
function mockReadline(inputs: string[]) {
let questionIdx = 0;
const emitter = new EventEmitter();
return {
async question(query: string) {
async question(_query: string) {
const answer = inputs[questionIdx++];
return answer ?? '';
},
@@ -26,7 +24,7 @@ function mockReadline(inputs: string[]) {
async next() {
return { done: true };
},
} as any;
} as unknown as ReadlineInterface;
}
describe('setupProviders', () => {
+7 -4
View File
@@ -1,7 +1,7 @@
import * as readline from 'node:readline';
import type { ManagedSession } from '../../session/index.js';
import type { ModelClient, TokenUsage } from '../../models/types.js';
import type { ModelRouter, ModelTier } from '../../models/router.js';
import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent } from '../../backends/native/agent.js';
import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js';
import { renderMarkdown } from './markdown.js';
@@ -406,13 +406,13 @@ export class MinimalTui {
}
});
});
} catch (error) {
} catch {
// Service might not exist or already stopped, ignore
console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`);
}
}
private async startBackend(provider: string, config: ModelConfig): Promise<void> {
private async startBackend(provider: string, _config: ModelConfig): Promise<void> {
try {
const { exec } = await import('child_process');
let serviceName: string;
@@ -454,7 +454,10 @@ export class MinimalTui {
const promptHidden = async (question: string): Promise<string> => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const rlAny = rl as any;
const rlAny = rl as readline.Interface & {
stdoutMuted?: boolean;
_writeToOutput?: (s: string) => void;
};
rlAny.stdoutMuted = true;
rlAny._writeToOutput = (s: string) => {
if (!rlAny.stdoutMuted) {
+9 -7
View File
@@ -135,6 +135,8 @@ export class GatewayServer {
}
private registerHandlers(): void {
const channelRegistry = this.config.channelRegistry;
const runtimeConfig = this.config.config;
const systemHandlers = createSystemHandlers({
startTime: this.startTime,
version: this.config.version ?? '0.1.0',
@@ -142,14 +144,14 @@ export class GatewayServer {
getToolCount: () => this.config.toolRegistry.list().length,
getConnectionCount: () => this.sessionBridge.connectionCount,
restart: this.config.restart,
getChannels: this.config.channelRegistry
? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status }))
getChannels: channelRegistry
? () => channelRegistry.list().map(a => ({ name: a.name, status: a.status }))
: undefined,
getServices: this.config.config && this.config.channelRegistry
? () => discoverServices(this.config.config!, this.config.channelRegistry!)
getServices: runtimeConfig && channelRegistry
? () => discoverServices(runtimeConfig, channelRegistry)
: undefined,
getPresence: this.config.channelRegistry
? (opts) => this.config.channelRegistry!.getPresence(opts)
getPresence: channelRegistry
? (opts) => channelRegistry.getPresence(opts)
: undefined,
getUsage: () => ({
totalSessions: this.config.sessionManager.listSessions().length,
@@ -307,7 +309,7 @@ export class GatewayServer {
});
}
private handleConnection(ws: WebSocket, identity?: string): void {
private handleConnection(ws: WebSocket, _identity?: string): void {
// Gateway lock — reject if another client is already connected
if (this.config.lock && this.connectionMap.size > 0) {
ws.close(4003, 'Gateway locked — another client is already connected');
+7 -4
View File
@@ -124,7 +124,10 @@ describe('SessionBridge', () => {
const bridge = createBridge();
bridge.connect('conn-1');
const agent = bridge.getAgent('conn-1');
const cancelSpy = vi.spyOn(agent!, 'cancel');
if (!agent) {
throw new Error('Expected agent for conn-1');
}
const cancelSpy = vi.spyOn(agent, 'cancel');
bridge.setBusy('conn-1', true);
expect(bridge.cancel('conn-1')).toBe(true);
@@ -193,7 +196,7 @@ describe('SessionBridge', () => {
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } },
} as any);
} as unknown as SessionBridgeConfig['config']);
bridge.connect('conn-tier');
const agent = bridge.getAgent('conn-tier');
@@ -201,7 +204,7 @@ describe('SessionBridge', () => {
});
it('keeps different sessions isolated by persisted model tier', () => {
const sessionById: Record<string, any> = {};
const sessionById: Record<string, typeof mockSession> = {};
const localSessionManager = {
...mockSessionManager,
getSession: vi.fn((frontend: string, sessionId: string) => {
@@ -238,7 +241,7 @@ describe('SessionBridge', () => {
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } },
} as any,
} as unknown as SessionBridgeConfig['config'],
});
bridge.connect('conn-a');
+5 -3
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { HybridSearch } from './hybrid-search.js';
import type { HybridSearchResult } from './hybrid-search.js';
import type { MemoryStore, SearchResult } from './store.js';
import type { VectorStore, VectorSearchResult } from './vector-store.js';
import type { EmbeddingProvider } from './embeddings.js';
@@ -153,7 +152,10 @@ describe('HybridSearch', () => {
const keywordResult = results.find((r) => r.source === 'keyword');
expect(vectorResult).toBeDefined();
expect(keywordResult).toBeDefined();
expect(vectorResult!.score).toBeGreaterThan(keywordResult!.score);
if (!vectorResult || !keywordResult) {
throw new Error('Expected both vector and keyword results');
}
expect(vectorResult.score).toBeGreaterThan(keywordResult.score);
});
it('falls back to keyword search when vector search fails', async () => {
+23 -9
View File
@@ -1,6 +1,6 @@
import Anthropic from '@anthropic-ai/sdk';
import type { Message as AnthropicMessage } from '@anthropic-ai/sdk/resources/messages/messages.js';
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, Message, MessageContentPart } from './types.js';
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, MessageContentPart } from './types.js';
export interface AnthropicClientConfig {
apiKey?: string; // Falls back to ANTHROPIC_API_KEY env var
@@ -23,21 +23,27 @@ function toAnthropicContent(content: string | MessageContentPart[]): string | un
}
if (part.type === 'image') {
if (part.source.type === 'base64') {
if (!part.source.data) {
return { type: 'text', text: '[Image omitted: missing base64 data]' };
}
return {
type: 'image',
source: {
type: 'base64',
media_type: part.source.media_type,
data: part.source.data!,
data: part.source.data,
},
};
}
if (!part.source.url) {
return { type: 'text', text: '[Image omitted: missing URL]' };
}
// URL-based image
return {
type: 'image',
source: {
type: 'url',
url: part.source.url!,
url: part.source.url,
},
};
}
@@ -52,6 +58,10 @@ function toAnthropicContent(content: string | MessageContentPart[]): string | un
});
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
}
export class AnthropicClient implements ModelClient {
private client: Anthropic;
private model: string;
@@ -67,14 +77,17 @@ export class AnthropicClient implements ModelClient {
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const params: Record<string, unknown> = {
type CreateParams = Parameters<typeof this.client.messages.create>[0] & {
thinking?: { type: 'enabled'; budget_tokens: number };
};
const params: CreateParams = {
model: this.model,
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
system: request.system,
messages: request.messages.map((m) => ({
role: m.role,
content: toAnthropicContent(m.content),
})),
})) as CreateParams['messages'],
};
if (request.tools && request.tools.length > 0) {
@@ -83,18 +96,19 @@ export class AnthropicClient implements ModelClient {
// Extended thinking mode — enable thinking with a budget
if (request.thinking) {
params.max_tokens = Math.max(params.max_tokens as number, 16384);
(params as any).thinking = { type: 'enabled', budget_tokens: 4096 };
params.max_tokens = Math.max(params.max_tokens, 16384);
params.thinking = { type: 'enabled', budget_tokens: 4096 };
}
const response = await this.client.messages.create(params as unknown as Parameters<typeof this.client.messages.create>[0]) as AnthropicMessage;
const response = await this.client.messages.create(params) as AnthropicMessage;
const textContent = response.content.find((c) => c.type === 'text');
const content = textContent?.type === 'text' ? textContent.text : '';
// Extract thinking content if present
const thinkingBlock = response.content.find((c) => c.type === 'thinking');
const thinkingContent = thinkingBlock && 'thinking' in thinkingBlock ? (thinkingBlock as any).text : undefined;
const thinkingText = asRecord(thinkingBlock)?.text;
const thinkingContent = typeof thinkingText === 'string' ? thinkingText : undefined;
const toolCalls = response.content
.filter((c): c is { type: 'tool_use'; id: string; name: string; input: unknown } => c.type === 'tool_use')
+12 -6
View File
@@ -137,8 +137,8 @@ export class OllamaClient implements ModelClient {
private async checkToolSupport(): Promise<boolean> {
if (this._supportsTools !== null) {return this._supportsTools;}
try {
const info = await this.client.show({ model: this.model });
const caps: string[] = (info as any).capabilities ?? [];
const info = await this.client.show({ model: this.model }) as { capabilities?: string[] };
const caps = info.capabilities ?? [];
this._supportsTools = caps.includes('tools');
} catch {
// Old Ollama or network issue — assume tools are supported
@@ -151,6 +151,12 @@ export class OllamaClient implements ModelClient {
* Convert Flynn ToolDefinition[] to Ollama Tool[] format.
*/
private convertTools(tools: ToolDefinition[]): Tool[] {
type OllamaParameter = {
type?: string | string[];
items?: unknown;
description?: string;
enum?: unknown[];
};
return tools.map(t => ({
type: 'function',
function: {
@@ -159,7 +165,7 @@ export class OllamaClient implements ModelClient {
parameters: {
type: t.input_schema.type,
required: t.input_schema.required,
properties: t.input_schema.properties as Record<string, any>,
properties: t.input_schema.properties as Record<string, OllamaParameter>,
},
},
}));
@@ -186,7 +192,7 @@ export class OllamaClient implements ModelClient {
// Extract content, checking for thinking field from reasoning models
let content = response.message.content;
let thinkingContent: string | undefined;
const thinking = (response.message as any).thinking;
const thinking = (response.message as unknown as { thinking?: unknown }).thinking;
if (thinking && typeof thinking === 'string') {
if (!content) {
// If no regular content, use thinking as content
@@ -245,7 +251,7 @@ export class OllamaClient implements ModelClient {
}
// Handle thinking field from reasoning models (e.g., deepseek-r1)
const thinking = (chunk.message as any)?.thinking;
const thinking = (chunk.message as unknown as { thinking?: unknown } | undefined)?.thinking;
if (thinking && typeof thinking === 'string') {
yield { type: 'content', content: thinking };
}
@@ -259,7 +265,7 @@ export class OllamaClient implements ModelClient {
if (chunk.done) {
// Handle tool_calls in the final chunk
const toolCalls = (chunk.message as any)?.tool_calls;
const toolCalls = (chunk.message as unknown as { tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }> } | undefined)?.tool_calls;
if (toolCalls && Array.isArray(toolCalls)) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
+8 -4
View File
@@ -12,7 +12,7 @@ vi.mock('../auth/openai.js', () => ({
})),
}));
function makeSse(events: Array<{ event: string; data: any }>): string {
function makeSse(events: Array<{ event: string; data: unknown }>): string {
return events
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
.join('');
@@ -39,8 +39,12 @@ describe('OpenAIClient OAuth (Codex)', () => {
{ event: 'response.completed', data: { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } } },
]);
globalThis.fetch = vi.fn(async (_url: any, init?: any) => {
const parsed = JSON.parse(init.body);
globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const body = typeof init?.body === 'string' ? init.body : '';
if (!body) {
throw new Error('Expected JSON body');
}
const parsed = JSON.parse(body) as Record<string, unknown>;
expect(parsed.store).toBe(false);
expect(parsed.stream).toBe(true);
expect(typeof parsed.instructions).toBe('string');
@@ -54,7 +58,7 @@ describe('OpenAIClient OAuth (Codex)', () => {
});
return new Response(stream, { status: 200 });
}) as any;
}) as typeof fetch;
const client = new OpenAIClient({ model: 'gpt-5.3-codex', useOAuth: true });
const resp = await client.chat({
+23 -8
View File
@@ -27,13 +27,22 @@ function toOpenAIContent(content: string | MessageContentPart[]): string | OpenA
return { type: 'text', text: part.text };
}
if (part.type === 'image') {
if (part.source.type === 'base64' && !part.source.data) {
return { type: 'text', text: '[Image omitted: missing base64 data]' };
}
if (part.source.type !== 'base64' && !part.source.url) {
return { type: 'text', text: '[Image omitted: missing URL]' };
}
// OpenAI accepts data URIs or regular URLs
const url = part.source.type === 'base64'
? `data:${part.source.media_type};base64,${part.source.data!}`
: part.source.url!;
? `data:${part.source.media_type};base64,${part.source.data}`
: part.source.url;
return { type: 'image_url', image_url: { url } };
}
if (part.type === 'audio') {
if (!part.source.data) {
return { type: 'text', text: '[Audio omitted: missing data]' };
}
// OpenAI native audio input via input_audio content part
// Determine format from MIME type (OpenAI supports: wav, mp3, flac, opus, ogg, webm)
const formatMap: Record<string, string> = {
@@ -157,9 +166,13 @@ export class OpenAIClient implements ModelClient {
}
}
if (!data) {return;}
let obj: any;
let obj: Record<string, unknown>;
try {
obj = JSON.parse(data);
const parsed = JSON.parse(data) as unknown;
if (!parsed || typeof parsed !== 'object') {
return;
}
obj = parsed as Record<string, unknown>;
} catch {
return;
}
@@ -169,8 +182,9 @@ export class OpenAIClient implements ModelClient {
}
if (obj.type === 'response.completed') {
const u = obj.response?.usage;
if (u) {
const response = obj.response as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined;
const u = response?.usage;
if (u && typeof u === 'object') {
usage = {
inputTokens: u.input_tokens ?? 0,
outputTokens: u.output_tokens ?? 0,
@@ -179,7 +193,8 @@ export class OpenAIClient implements ModelClient {
}
if (obj.type === 'response.failed') {
const detail = obj.response?.error?.message ?? 'OpenAI OAuth response failed';
const response = obj.response as { error?: { message?: string } } | undefined;
const detail = response?.error?.message ?? 'OpenAI OAuth response failed';
throw new Error(detail);
}
};
@@ -247,7 +262,7 @@ export class OpenAIClient implements ModelClient {
// Extended thinking/reasoning mode for o1/o3 models
if (request.thinking) {
(params as any).reasoning_effort = 'medium';
(params as OpenAI.ChatCompletionCreateParamsNonStreaming & { reasoning_effort?: 'low' | 'medium' | 'high' }).reasoning_effort = 'medium';
}
let response: OpenAI.ChatCompletion;
+21 -9
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ModelRouter } from './router.js';
import type { ModelClient, ChatResponse, ChatStreamEvent } from './types.js';
@@ -314,10 +314,13 @@ describe('setClient and labels', () => {
const newFastClient = router.getClient('fast');
expect(newFastClient).toBeDefined();
if (!newFastClient) {
throw new Error('Expected fast client to be set');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast');
expect(newFastClient!.chat).toHaveBeenCalled();
expect(newFastClient!.chat).toHaveBeenCalledTimes(1);
expect(newFastClient.chat).toHaveBeenCalled();
expect(newFastClient.chat).toHaveBeenCalledTimes(1);
expect(mockClient1.chat).toHaveBeenCalledTimes(1);
});
@@ -336,10 +339,13 @@ describe('setClient and labels', () => {
const newClient = router.getClient('complex');
expect(newClient).toBe(mockClient2);
if (!newClient) {
throw new Error('Expected complex client to be set');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'complex');
expect(newClient!.chat).toHaveBeenCalled();
expect(newClient.chat).toHaveBeenCalled();
});
it('getLabel returns the label set by setClient', () => {
@@ -424,19 +430,25 @@ describe('setClient and labels', () => {
const initialFastClient = router.getClient('fast');
expect(initialFastClient).toBeDefined();
if (!initialFastClient) {
throw new Error('Expected initial fast client to exist');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast');
expect(initialFastClient!.chat).toHaveBeenCalled();
expect(initialFastClient!.chat).toHaveBeenCalledTimes(1);
expect(initialFastClient.chat).toHaveBeenCalled();
expect(initialFastClient.chat).toHaveBeenCalledTimes(1);
router.setClient('fast', mockClient2, 'fast-replaced');
const newFastClient = router.getClient('fast');
if (!newFastClient) {
throw new Error('Expected replaced fast client to exist');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast');
expect(newFastClient!.chat).toHaveBeenCalled();
expect(newFastClient!.chat).toHaveBeenCalledTimes(1);
expect(initialFastClient!.chat).toHaveBeenCalledTimes(1);
expect(newFastClient.chat).toHaveBeenCalled();
expect(newFastClient.chat).toHaveBeenCalledTimes(1);
expect(initialFastClient.chat).toHaveBeenCalledTimes(1);
});
it('strict tier mode disables fallback chain for that tier', async () => {
+14 -5
View File
@@ -62,8 +62,11 @@ describe('SkillInstaller', () => {
const skill = installer.install(sourceDir);
expect(skill).not.toBeNull();
expect(skill!.manifest.name).toBe('my-skill');
expect(skill!.instructions).toBe('# My Skill\nDo the thing.');
if (!skill) {
throw new Error('Expected installed skill');
}
expect(skill.manifest.name).toBe('my-skill');
expect(skill.instructions).toBe('# My Skill\nDo the thing.');
expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true);
});
@@ -79,7 +82,10 @@ describe('SkillInstaller', () => {
const skill = installer.install(sourceDir);
expect(skill).not.toBeNull();
expect(skill!.manifest.tier).toBe('managed');
if (!skill) {
throw new Error('Expected installed skill');
}
expect(skill.manifest.tier).toBe('managed');
});
it('uses manifest.json name field for the installed directory name', () => {
@@ -131,8 +137,11 @@ describe('SkillInstaller', () => {
const skill = installer.install(sourceV2);
expect(skill).not.toBeNull();
expect(skill!.manifest.version).toBe('2.0.0');
expect(skill!.instructions).toBe('# V2');
if (!skill) {
throw new Error('Expected installed skill');
}
expect(skill.manifest.version).toBe('2.0.0');
expect(skill.instructions).toBe('# V2');
});
it('throws when source directory does not exist', () => {