chore(lint): reduce warning debt across core adapters and model clients
This commit is contained in:
+23
-9
@@ -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')
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user