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:
can1357
2026-03-08 00:00:42 +01:00
parent 7d5a230e26
commit c4946363ca
13 changed files with 414 additions and 62 deletions
+12
View File
@@ -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
+1 -11
View File
@@ -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,
+3 -2
View File
@@ -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");
});
});
+6 -4
View File
@@ -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">;
+9
View File
@@ -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": [
+62 -7
View File
@@ -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?"],
});
});
});