feat(coding-agent): added Ollama capability detection and improved provider integrations
- Added automatic Ollama model capability detection via /api/show endpoint to discover reasoning and input modality support. - Improved Kagi API error handling with structured error parsing for JSON and plain text response formats. - Fixed Cerebras streaming compatibility by omitting stream_options.include_usage parameter. - Simplified API key credential storage to always replace credentials instead of merging for non-minimax providers. - Updated Kagi Search API key format from 'kagi_...' to 'KG_...' and clarified beta access requirement in provider description. Fixes #326. Fixes #321. Fixes #298.
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Simplified API key credential storage to always replace existing credentials on re-login instead of accumulating multiple keys
|
||||
- Updated Kagi API key placeholder from `kagi_...` to `KG_...` to match current API key format
|
||||
- Updated Kagi login instructions to clarify Search API access is beta-only and provide support contact
|
||||
- Disabled usage reporting in streaming responses for Cerebras models due to compatibility issues
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Cerebras model compatibility by preventing `stream_options` usage requests in chat completions
|
||||
|
||||
## [13.9.3] - 2026-03-07
|
||||
### Breaking Changes
|
||||
@@ -77,6 +87,8 @@
|
||||
- Fixed OpenAI Codex streaming to properly include service_tier in SSE payloads
|
||||
- Fixed type safety in OpenAI responses by removing unsafe type casts on image content blocks
|
||||
- Fixed credential purging to respect disabled credentials when deduplicating by email
|
||||
- Fixed API-key provider re-login to replace the active stored key instead of appending stale credentials that were still selected first
|
||||
- Fixed Kagi login guidance to use the correct `KG_...` key format and mention Search API beta access requirements
|
||||
|
||||
## [13.9.2] - 2026-03-05
|
||||
|
||||
|
||||
@@ -773,17 +773,7 @@ export class AuthStorage {
|
||||
let credentials: OAuthCredentials;
|
||||
const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
|
||||
const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
|
||||
const shouldReplaceExisting = provider === "minimax-code" || provider === "minimax-code-cn";
|
||||
if (shouldReplaceExisting) {
|
||||
await this.set(provider, newCredential);
|
||||
return;
|
||||
}
|
||||
const existing = this.#getCredentialsForProvider(provider);
|
||||
if (existing.length === 0) {
|
||||
await this.set(provider, newCredential);
|
||||
return;
|
||||
}
|
||||
await this.set(provider, [...existing, newCredential]);
|
||||
await this.set(provider, newCredential);
|
||||
};
|
||||
const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
|
||||
switch (provider) {
|
||||
|
||||
@@ -1067,13 +1067,13 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat
|
||||
const provider = model.provider;
|
||||
const baseUrl = model.baseUrl;
|
||||
|
||||
const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
|
||||
const isZai = provider === "zai" || baseUrl.includes("api.z.ai");
|
||||
const isOpenRouterKimi = provider === "openrouter" && model.id.includes("moonshotai/kimi");
|
||||
const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope");
|
||||
|
||||
const isNonStandard =
|
||||
provider === "cerebras" ||
|
||||
baseUrl.includes("cerebras.ai") ||
|
||||
isCerebras ||
|
||||
provider === "xai" ||
|
||||
baseUrl.includes("api.x.ai") ||
|
||||
provider === "mistral" ||
|
||||
@@ -1096,7 +1096,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat
|
||||
supportsStore: !isNonStandard,
|
||||
supportsDeveloperRole: !isNonStandard,
|
||||
supportsReasoningEffort: !isGrok && !isZai,
|
||||
supportsUsageInStreaming: true,
|
||||
supportsUsageInStreaming: !isCerebras,
|
||||
supportsToolChoice: true,
|
||||
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
|
||||
requiresToolResultName: isMistral,
|
||||
|
||||
@@ -25,12 +25,13 @@ export async function loginKagi(options: OAuthController): Promise<string> {
|
||||
|
||||
options.onAuth?.({
|
||||
url: AUTH_URL,
|
||||
instructions: "Copy your API key from Kagi API settings",
|
||||
instructions:
|
||||
"Copy your Kagi Search API key from Kagi API settings. Search API access is beta-only; if unavailable, email support@kagi.com.",
|
||||
});
|
||||
|
||||
const apiKey = await options.onPrompt({
|
||||
message: "Paste your Kagi API key",
|
||||
placeholder: "kagi_...",
|
||||
placeholder: "KG_...",
|
||||
});
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
vi.mock("../src/utils/oauth/kagi", () => ({
|
||||
loginKagi: vi.fn(),
|
||||
}));
|
||||
|
||||
import { AuthCredentialStore, AuthStorage } from "../src/auth-storage";
|
||||
import { loginKagi } from "../src/utils/oauth/kagi";
|
||||
|
||||
type MockedApiKeyLogin = {
|
||||
mockReset(): void;
|
||||
mockResolvedValueOnce(value: string): MockedApiKeyLogin;
|
||||
};
|
||||
|
||||
const mockedLoginKagi = loginKagi as typeof loginKagi & MockedApiKeyLogin;
|
||||
|
||||
describe("AuthStorage api-key login replacement", () => {
|
||||
let tempDir = "";
|
||||
let store: AuthCredentialStore | null = null;
|
||||
let authStorage: AuthStorage | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-api-key-login-"));
|
||||
store = await AuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
authStorage = new AuthStorage(store);
|
||||
mockedLoginKagi.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
store?.close();
|
||||
store = null;
|
||||
authStorage = null;
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces the active api-key credential on re-login", async () => {
|
||||
if (!store || !authStorage) throw new Error("test setup failed");
|
||||
|
||||
mockedLoginKagi.mockResolvedValueOnce("first-kagi-key").mockResolvedValueOnce("second-kagi-key");
|
||||
|
||||
const controller = {
|
||||
onAuth: () => {},
|
||||
onPrompt: async () => "",
|
||||
};
|
||||
|
||||
await authStorage.login("kagi", controller);
|
||||
await authStorage.login("kagi", controller);
|
||||
|
||||
const credentials = store.listAuthCredentials("kagi");
|
||||
expect(credentials).toHaveLength(1);
|
||||
const [stored] = credentials;
|
||||
expect(stored?.credential.type).toBe("api_key");
|
||||
if (!stored || stored.credential.type !== "api_key") {
|
||||
throw new Error("expected stored api-key credential");
|
||||
}
|
||||
expect(stored.credential.key).toBe("second-kagi-key");
|
||||
expect(store.getApiKey("kagi")).toBe("second-kagi-key");
|
||||
expect(await authStorage.getApiKey("kagi", "session-kagi-relogin")).toBe("second-kagi-key");
|
||||
});
|
||||
});
|
||||
@@ -16,15 +16,17 @@ describe("kagi login", () => {
|
||||
onPrompt: async prompt => {
|
||||
promptMessage = prompt.message;
|
||||
promptPlaceholder = prompt.placeholder;
|
||||
return "kagi_test_key";
|
||||
return " KG_test_key ";
|
||||
},
|
||||
});
|
||||
|
||||
expect(authUrl).toBe("https://kagi.com/settings/api");
|
||||
expect(authInstructions).toContain("Copy your API key");
|
||||
expect(authInstructions).toContain("Kagi Search API key");
|
||||
expect(authInstructions).toContain("beta-only");
|
||||
expect(authInstructions).toContain("support@kagi.com");
|
||||
expect(promptMessage).toBe("Paste your Kagi API key");
|
||||
expect(promptPlaceholder).toBe("kagi_...");
|
||||
expect(apiKey).toBe("kagi_test_key");
|
||||
expect(promptPlaceholder).toBe("KG_...");
|
||||
expect(apiKey).toBe("KG_test_key");
|
||||
});
|
||||
|
||||
it("rejects empty keys", async () => {
|
||||
|
||||
@@ -91,6 +91,15 @@ describe("OpenAI tool strict mode", () => {
|
||||
};
|
||||
expect(payload.tools?.[0]?.function?.strict).toBe(true);
|
||||
});
|
||||
it("omits stream_options usage requests for Cerebras chat completions", async () => {
|
||||
const model = getBundledModel("cerebras", "gpt-oss-120b") as Model<"openai-completions">;
|
||||
|
||||
const payload = (await captureCompletionsPayload(model)) as {
|
||||
stream_options?: { include_usage?: boolean };
|
||||
};
|
||||
expect(payload.stream_options).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends strict=true for openai-responses tool schemas on OpenAI", async () => {
|
||||
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Automatic detection of Ollama model capabilities including reasoning/thinking support and vision input via the `/api/show` endpoint
|
||||
- Improved Kagi API error handling with extraction of detailed error messages from JSON and plain text responses
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Kagi provider description to clarify requirement for Kagi Search API beta access
|
||||
|
||||
## [13.9.3] - 2026-03-07
|
||||
|
||||
@@ -54,6 +62,7 @@
|
||||
- Fixed model registry to preserve explicit thinking configuration on runtime-registered models
|
||||
- Fixed usage limit reset time calculation to use absolute `resetsAt` timestamps instead of deprecated `resetInMs` field
|
||||
- Fixed compaction summary message creation to no longer be automatically added to chat during compaction (now handled by session manager)
|
||||
- Fixed Kagi web search errors to surface the provider's beta-access message and clarified that Kagi search requires Search API beta access
|
||||
|
||||
## [13.9.2] - 2026-03-05
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
unregisterCustomApis,
|
||||
unregisterOAuthProviders,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { isRecord, logger } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { type ConfigError, ConfigFile } from "../config";
|
||||
import type { ThemeColor } from "../modes/theme/theme";
|
||||
@@ -862,12 +862,57 @@ export class ModelRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
async #discoverOllamaModelMetadata(
|
||||
endpoint: string,
|
||||
modelId: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
): Promise<{ reasoning: boolean; input: ("text" | "image")[] } | null> {
|
||||
const showUrl = `${endpoint}/api/show`;
|
||||
try {
|
||||
const response = await fetch(showUrl, {
|
||||
method: "POST",
|
||||
headers: { ...(headers ?? {}), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: modelId }),
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const capabilities = payload.capabilities;
|
||||
if (Array.isArray(capabilities)) {
|
||||
const normalized = new Set(
|
||||
capabilities.flatMap(capability => (typeof capability === "string" ? [capability.toLowerCase()] : [])),
|
||||
);
|
||||
const supportsVision = normalized.has("vision") || normalized.has("image");
|
||||
return {
|
||||
reasoning: normalized.has("thinking"),
|
||||
input: supportsVision ? ["text", "image"] : ["text"],
|
||||
};
|
||||
}
|
||||
if (!isRecord(capabilities)) {
|
||||
return null;
|
||||
}
|
||||
const supportsVision = capabilities.vision === true || capabilities.image === true;
|
||||
return {
|
||||
reasoning: capabilities.thinking === true,
|
||||
input: supportsVision ? ["text", "image"] : ["text"],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
|
||||
const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl);
|
||||
const tagsUrl = `${endpoint}/api/tags`;
|
||||
const headers = { ...(providerConfig.headers ?? {}) };
|
||||
try {
|
||||
const response = await fetch(tagsUrl, {
|
||||
headers: { ...(providerConfig.headers ?? {}) },
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -879,27 +924,34 @@ export class ModelRegistry {
|
||||
return [];
|
||||
}
|
||||
const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
|
||||
const models = payload.models ?? [];
|
||||
const discovered: Model<Api>[] = [];
|
||||
for (const item of models) {
|
||||
const entries = (payload.models ?? []).flatMap(item => {
|
||||
const id = item.model || item.name;
|
||||
if (!id) continue;
|
||||
discovered.push(
|
||||
enrichModelThinking({
|
||||
id,
|
||||
name: item.name || id,
|
||||
api: providerConfig.api,
|
||||
provider: providerConfig.provider,
|
||||
baseUrl: `${endpoint}/v1`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
headers: providerConfig.headers,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return id ? [{ id, name: item.name || id }] : [];
|
||||
});
|
||||
const metadataById = new Map(
|
||||
await Promise.all(
|
||||
entries.map(
|
||||
async entry =>
|
||||
[entry.id, await this.#discoverOllamaModelMetadata(endpoint, entry.id, headers)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
const discovered = entries.map(entry => {
|
||||
const metadata = metadataById.get(entry.id);
|
||||
return enrichModelThinking({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
api: providerConfig.api,
|
||||
provider: providerConfig.provider,
|
||||
baseUrl: `${endpoint}/v1`,
|
||||
reasoning: metadata?.reasoning ?? false,
|
||||
input: metadata?.input ?? ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
headers: providerConfig.headers,
|
||||
});
|
||||
});
|
||||
return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
|
||||
} catch (error) {
|
||||
logger.warn("model discovery failed for provider", {
|
||||
|
||||
@@ -234,7 +234,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
||||
{ value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" },
|
||||
{ value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
|
||||
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
|
||||
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY" },
|
||||
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
|
||||
{ value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
|
||||
],
|
||||
"providers.image": [
|
||||
|
||||
@@ -28,15 +28,23 @@ interface KagiRelatedSearchesObject {
|
||||
|
||||
type KagiSearchObject = KagiSearchResultObject | KagiRelatedSearchesObject;
|
||||
|
||||
interface KagiErrorEntry {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
interface KagiSearchResponse {
|
||||
meta: {
|
||||
id: string;
|
||||
};
|
||||
data: KagiSearchObject[];
|
||||
error?: Array<{
|
||||
code: number;
|
||||
msg: string;
|
||||
}>;
|
||||
error?: KagiErrorEntry[];
|
||||
}
|
||||
|
||||
interface KagiErrorResponse {
|
||||
error?: string | KagiErrorEntry[];
|
||||
message?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export class KagiApiError extends Error {
|
||||
@@ -49,6 +57,54 @@ export class KagiApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function extractKagiErrorMessage(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
|
||||
for (const value of [record.message, record.detail]) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof record.error === "string" && record.error.trim().length > 0) {
|
||||
return record.error.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(record.error)) {
|
||||
for (const entry of record.error) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const message = (entry as Record<string, unknown>).msg;
|
||||
if (typeof message === "string" && message.trim().length > 0) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createKagiApiError(statusCode: number, detail?: string): KagiApiError {
|
||||
return new KagiApiError(
|
||||
detail ? `Kagi API error (${statusCode}): ${detail}` : `Kagi API error (${statusCode})`,
|
||||
statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
function parseKagiErrorResponse(statusCode: number, responseText: string): KagiApiError {
|
||||
const trimmedResponseText = responseText.trim();
|
||||
if (trimmedResponseText.length === 0) {
|
||||
return createKagiApiError(statusCode);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(trimmedResponseText) as KagiErrorResponse;
|
||||
return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmedResponseText);
|
||||
} catch {
|
||||
return createKagiApiError(statusCode, trimmedResponseText);
|
||||
}
|
||||
}
|
||||
|
||||
export interface KagiSummarizeOptions {
|
||||
engine?: string;
|
||||
summaryType?: string;
|
||||
@@ -127,14 +183,13 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions =
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new KagiApiError(`Kagi API error (${response.status}): ${errorText}`, response.status);
|
||||
throw parseKagiErrorResponse(response.status, await response.text());
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as KagiSearchResponse;
|
||||
if (payload.error && payload.error.length > 0) {
|
||||
const firstError = payload.error[0];
|
||||
throw new KagiApiError(`Kagi API error: ${firstError.msg}`, firstError.code);
|
||||
throw createKagiApiError(firstError.code ?? response.status, extractKagiErrorMessage(payload) ?? undefined);
|
||||
}
|
||||
|
||||
const sources: KagiSearchSource[] = [];
|
||||
|
||||
@@ -665,11 +665,20 @@ describe("ModelRegistry", () => {
|
||||
test("auto-discovers ollama models without provider config", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
expect(String(input)).toBe("http://127.0.0.1:11434/api/tags");
|
||||
return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:11434/api/tags") {
|
||||
return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "http://127.0.0.1:11434/api/show") {
|
||||
return new Response(JSON.stringify({ capabilities: ["completion"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
@@ -696,13 +705,22 @@ describe("ModelRegistry", () => {
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
expect(String(input)).toBe("http://127.0.0.1:11434/api/tags");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ name: "qwen2.5-coder:7b" }, { model: "llama3.2:3b", name: "llama3.2:3b" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:11434/api/tags") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ name: "qwen2.5-coder:7b" }, { model: "llama3.2:3b", name: "llama3.2:3b" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "http://127.0.0.1:11434/api/show") {
|
||||
return new Response(JSON.stringify({ capabilities: ["completion"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
@@ -721,6 +739,64 @@ describe("ModelRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("discovers ollama thinking capabilities from show metadata", async () => {
|
||||
writeRawModelsJson({
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
api: "openai-completions",
|
||||
auth: "none",
|
||||
discovery: { type: "ollama" },
|
||||
},
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:11434/api/tags") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ name: "qwen3.5:397b-cloud" }, { name: "llama3.2:3b" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "http://127.0.0.1:11434/api/show") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||
if (body.model === "qwen3.5:397b-cloud") {
|
||||
return new Response(JSON.stringify({ capabilities: ["completion", "thinking"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (body.model === "llama3.2:3b") {
|
||||
return new Response(JSON.stringify({ capabilities: ["completion"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
await registry.refresh();
|
||||
|
||||
const qwen = registry.find("ollama", "qwen3.5:397b-cloud");
|
||||
expect(qwen?.reasoning).toBe(true);
|
||||
expect(qwen?.thinking).toEqual({
|
||||
mode: "effort",
|
||||
minLevel: Effort.Minimal,
|
||||
maxLevel: Effort.High,
|
||||
});
|
||||
|
||||
const llama = registry.find("ollama", "llama3.2:3b");
|
||||
expect(llama?.reasoning).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery failure does not fail model registry refresh", async () => {
|
||||
writeRawModelsJson({
|
||||
ollama: {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { searchWithKagi } from "../../src/web/kagi";
|
||||
import { searchKagi } from "../../src/web/search/providers/kagi";
|
||||
import { SearchProviderError } from "../../src/web/search/types";
|
||||
|
||||
describe("Kagi web search error handling", () => {
|
||||
beforeEach(() => {
|
||||
process.env.KAGI_API_KEY = "test-kagi-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.KAGI_API_KEY;
|
||||
});
|
||||
|
||||
it("surfaces beta access denial messages from JSON error bodies", async () => {
|
||||
const providerMessage =
|
||||
"Kagi Search API is in beta. Please contact support@kagi.com to enable API access for your account.";
|
||||
|
||||
// @ts-expect-error test mock does not implement fetch.preconnect
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: [{ code: 401, msg: providerMessage }] }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await searchKagi({ query: "kagi beta" });
|
||||
expect.unreachable("expected searchKagi to throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SearchProviderError);
|
||||
expect(error).toMatchObject({ provider: "kagi", status: 401 });
|
||||
expect((error as Error).message).toContain(providerMessage);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to plain text for non-JSON error bodies", async () => {
|
||||
// @ts-expect-error test mock does not implement fetch.preconnect
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("upstream unavailable", { status: 503 }));
|
||||
|
||||
await expect(searchWithKagi("plain text error")).rejects.toThrow("Kagi API error (503): upstream unavailable");
|
||||
});
|
||||
|
||||
it("preserves successful search parsing", async () => {
|
||||
// @ts-expect-error test mock does not implement fetch.preconnect
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
meta: { id: "req-kagi-success" },
|
||||
data: [
|
||||
{
|
||||
t: 0,
|
||||
url: "https://example.com/article",
|
||||
title: "Example Article",
|
||||
snippet: "Example snippet",
|
||||
published: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
{ t: 1, list: ["What is Kagi Search API beta access?"] },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(searchWithKagi("success case")).resolves.toEqual({
|
||||
requestId: "req-kagi-success",
|
||||
sources: [
|
||||
{
|
||||
title: "Example Article",
|
||||
url: "https://example.com/article",
|
||||
snippet: "Example snippet",
|
||||
publishedDate: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
relatedQuestions: ["What is Kagi Search API beta access?"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user