From c4946363cad4e54cce535c970bd8d732a93e845e Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 8 Mar 2026 00:00:42 +0100 Subject: [PATCH] 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. --- packages/ai/CHANGELOG.md | 12 +++ packages/ai/src/auth-storage.ts | 12 +-- .../ai/src/providers/openai-completions.ts | 6 +- packages/ai/src/utils/oauth/kagi.ts | 5 +- .../test/auth-storage-api-key-login.test.ts | 67 ++++++++++++ packages/ai/test/kagi-login.test.ts | 10 +- .../ai/test/openai-tool-strict-mode.test.ts | 9 ++ packages/coding-agent/CHANGELOG.md | 9 ++ .../coding-agent/src/config/model-registry.ts | 96 +++++++++++++---- .../src/modes/components/settings-defs.ts | 2 +- packages/coding-agent/src/web/kagi.ts | 69 ++++++++++-- .../coding-agent/test/model-registry.test.ts | 100 +++++++++++++++--- .../test/tools/web-search-kagi.test.ts | 79 ++++++++++++++ 13 files changed, 414 insertions(+), 62 deletions(-) create mode 100644 packages/ai/test/auth-storage-api-key-login.test.ts create mode 100644 packages/coding-agent/test/tools/web-search-kagi.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e91fc4e6a..a4bd655dd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index a403cf89f..dbc586db7 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -773,17 +773,7 @@ export class AuthStorage { let credentials: OAuthCredentials; const saveApiKeyCredential = async (apiKey: string): Promise => { 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) { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index b88499c5c..3cb3e7d57 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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, diff --git a/packages/ai/src/utils/oauth/kagi.ts b/packages/ai/src/utils/oauth/kagi.ts index fb5fa21f9..3833c43ad 100644 --- a/packages/ai/src/utils/oauth/kagi.ts +++ b/packages/ai/src/utils/oauth/kagi.ts @@ -25,12 +25,13 @@ export async function loginKagi(options: OAuthController): Promise { 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) { diff --git a/packages/ai/test/auth-storage-api-key-login.test.ts b/packages/ai/test/auth-storage-api-key-login.test.ts new file mode 100644 index 000000000..cf39967b1 --- /dev/null +++ b/packages/ai/test/auth-storage-api-key-login.test.ts @@ -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"); + }); +}); diff --git a/packages/ai/test/kagi-login.test.ts b/packages/ai/test/kagi-login.test.ts index 145d5206f..37686a0b4 100644 --- a/packages/ai/test/kagi-login.test.ts +++ b/packages/ai/test/kagi-login.test.ts @@ -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 () => { diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index e6d394169..f107943cd 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -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">; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3b7a93c6f..dfcd3e72a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 37750f1f7..8a2804b0f 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -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 | 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[]> { 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[] = []; - 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", { diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index f82f3014c..cd40b15bd 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -234,7 +234,7 @@ const OPTION_PROVIDERS: Partial> = { { 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": [ diff --git a/packages/coding-agent/src/web/kagi.ts b/packages/coding-agent/src/web/kagi.ts index eeb7aa83c..920075902 100644 --- a/packages/coding-agent/src/web/kagi.ts +++ b/packages/coding-agent/src/web/kagi.ts @@ -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; + + 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).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[] = []; diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index a0eefd746..e947c0472 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -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: { diff --git a/packages/coding-agent/test/tools/web-search-kagi.test.ts b/packages/coding-agent/test/tools/web-search-kagi.test.ts new file mode 100644 index 000000000..c0ef5c819 --- /dev/null +++ b/packages/coding-agent/test/tools/web-search-kagi.test.ts @@ -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?"], + }); + }); +});