feat(ai): add Exa API key login

This commit is contained in:
Will
2026-07-25 19:08:02 -04:00
parent 667111575e
commit 19d7d14a94
11 changed files with 66 additions and 7 deletions
+1 -1
View File
@@ -373,7 +373,7 @@ Twenty-five backends. Pin one, or let `auto` walk the chain in order.
| `codex` | oauth |
| `xai` | `XAI_API_KEY` |
| `zai` | `ZAI_API_KEY` |
| `exa` | `EXA_API_KEY` (or mcp) |
| `exa` | `/login` / env / mcp |
| `tinyfish` | `TINYFISH_API_KEY` |
| `jina` | `JINA_API_KEY` |
| `kagi` | `KAGI_API_KEY` |
+1 -1
View File
@@ -242,7 +242,7 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth
| Variable | Used by |
| --------------------------------------------------- | ------------------------------------------------------------- |
| `EXA_API_KEY` | Exa search provider and Exa MCP tools |
| `EXA_API_KEY` | Exa search/MCP; alternatively use `/login exa` |
| `BRAVE_API_KEY` | Brave search provider |
| `PERPLEXITY_API_KEY` | Perplexity search provider API-key mode |
| `PERPLEXITY_COOKIES` | Perplexity cookie-auth search mode |
+2 -2
View File
@@ -147,7 +147,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
- `limit` and `num_search_results` are collapsed together before dispatch.
- Output may include parsed free-text `answer`, `sources`, `requestId`.
- **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts`
- Availability: env or `agent.db` credential for `exa` admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (listing `exa` in `providers.webSearchOrder`, or a forced `provider: exa`) reaches Exa even without a credential and falls back to public MCP.
- Availability: `EXA_API_KEY` or a stored credential for `exa` (including one added through `/login exa`) admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (listing `exa` in `providers.webSearchOrder`, or a forced `provider: exa`) reaches Exa even without a credential and falls back to public MCP.
- Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`.
- `limit` and `num_search_results` are collapsed together before dispatch.
- Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`.
@@ -279,4 +279,4 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
- `recency` is implemented by Brave, Perplexity, Tavily, SearXNG, Kagi, TinyFish, Firecrawl, xAI, DuckDuckGo, Bing, Yahoo, Startpage, Google, and Mojeek (Ecosia ignores it; Public Web passes it through). The model-facing prompt does not name specific providers.
- `packages/coding-agent/src/config/settings-schema.ts` uses the shared `SEARCH_PROVIDER_PREFERENCES` / `SEARCH_PROVIDER_OPTIONS` metadata, so the settings selector and setup wizard expose `auto` plus every provider in the auto chain.
- The credential-free scrapers close the auto chain, cheap plain-fetch engines first (`duckduckgo`, `bing`, `yahoo`, `startpage`) and browser-backed ones after (`google`, `ecosia`, `mojeek`); `public` is listed last and never auto-selected.
- Exa uses `authStorage.getApiKey("exa")`, then `EXA_API_KEY`, then unauthenticated `https://mcp.exa.ai/mcp` fallback.
- `/login exa` stores the pasted key in AuthStorage; Exa resolves credentials in order from `authStorage.getApiKey("exa")`, then `EXA_API_KEY`, then the unauthenticated `https://mcp.exa.ai/mcp` fallback.
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added interactive Exa API-key login through `/login exa`, opening the official API-key dashboard and saving pasted keys to the credential store ([#1798](https://github.com/can1357/oh-my-pi/issues/1798)).
- OAuth logins now stamp `authorizedAt` (epoch ms of the interactive login) on the stored credential, and every refresh-persist path preserves it. Anthropic expires the whole OAuth grant family ~30 days after authorization regardless of refresh-token rotation (observed as `invalid_grant: "Refresh token expired"` on the latest rotated token, exactly 30 days after login, across four production accounts), so the login anchor is what makes re-login deadlines computable. Exported `ANTHROPIC_OAUTH_GRANT_TTL_MS` alongside the anthropic OAuth flow.
- Added `GET /v1/credentials/disabled` to the auth broker and `AuthBrokerClient.listDisabledCredentials`: disabled-credential tombstones (`DisabledCredentialSummary` — identity, verbatim disable cause, disable timestamp; never token material) so auto-disabled accounts stay visible to clients instead of silently vanishing from the snapshot. `AuthStorage.listDisabledCredentials` serves the same data locally from SQLite; clients of brokers predating the endpoint get an empty list (404 mapped, no error).
- Added `AuthStorage.revalidateCredentials()` and the optional `AuthCredentialStore.refreshSnapshot` hook: remote broker stores re-fetch `GET /v1/snapshot` on demand so callers pairing live per-credential data with stored identities (`omp usage`) never render against the up-to-an-hour-stale disk-cached snapshot; local SQLite stores are always current and only reload.
+19
View File
@@ -0,0 +1,19 @@
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const loginExa = createApiKeyLogin({
providerLabel: "Exa",
authUrl: "https://dashboard.exa.ai/api-keys",
instructions: "Create or copy your API key from the Exa dashboard.",
promptMessage: "Paste your Exa API key",
placeholder: "API key",
validation: null,
});
export const exaProvider = {
id: "exa",
name: "Exa",
envKeys: "EXA_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginExa(cb),
} as const satisfies ProviderDefinition;
+2
View File
@@ -12,6 +12,7 @@ import { coreWeaveProvider } from "./coreweave";
import { cursorProvider } from "./cursor";
import { deepseekProvider } from "./deepseek";
import { devinProvider } from "./devin";
import { exaProvider } from "./exa";
import { firepassProvider } from "./firepass";
import { fireworksProvider } from "./fireworks";
import { githubCopilotProvider } from "./github-copilot";
@@ -134,6 +135,7 @@ const ALL = [
opencodeGoProvider,
tavilyProvider,
kagiProvider,
exaProvider,
parallelProvider,
ollamaProvider,
ollamaCloudProvider,
-1
View File
@@ -691,7 +691,6 @@ type KeyResolver = string | (() => string | undefined);
const LEGACY_ENV_KEYS: Record<string, KeyResolver> = {
// Non-provider / search-tool keys and API-name keys not modeled as registry provider defs.
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
exa: "EXA_API_KEY",
jina: "JINA_API_KEY",
brave: "BRAVE_API_KEY",
tinyfish: "TINYFISH_API_KEY",
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test";
import { loginExa } from "@oh-my-pi/pi-ai/registry/exa";
describe("exa login", () => {
it("opens Exa API-key settings and returns a trimmed key without validation requests", async () => {
let authUrl: string | undefined;
let authInstructions: string | undefined;
let promptMessage: string | undefined;
let promptPlaceholder: string | undefined;
const apiKey = await loginExa({
onAuth: info => {
authUrl = info.url;
authInstructions = info.instructions;
},
onPrompt: async prompt => {
promptMessage = prompt.message;
promptPlaceholder = prompt.placeholder;
return " exa-test-key ";
},
fetch: () => {
throw new Error("Exa login must not make a network request");
},
});
expect(authUrl).toBe("https://dashboard.exa.ai/api-keys");
expect(authInstructions).toBe("Create or copy your API key from the Exa dashboard.");
expect(promptMessage).toBe("Paste your Exa API key");
expect(promptPlaceholder).toBe("API key");
expect(apiKey).toBe("exa-test-key");
});
});
+2 -1
View File
@@ -47,7 +47,7 @@ describe("provider registry auth surface", () => {
expect(getEnvApiKey("umans")).toBe("umans-env");
Bun.env.LLAMA_CPP_API_KEY = "llama-env";
expect(getEnvApiKey("llama.cpp")).toBe("llama-env");
// Legacy search-tool key preserved (not a registry provider def).
// Exa is derived from the provider registry's `envKeys` definition.
expect(getEnvApiKey("exa")).toBe("exa-env");
});
@@ -65,6 +65,7 @@ describe("provider registry auth surface", () => {
const ids = getOAuthProviders().map(provider => provider.id);
expect(ids).toContain("zenmux");
expect(ids).toContain("kagi");
expect(ids).toContain("exa");
expect(ids).toContain("umans");
expect(ids).toContain("llama.cpp");
// openai has no interactive login flow.
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added interactive Exa API-key onboarding through `/login exa`, opening the official key dashboard and saving pasted keys for authenticated web search while preserving `EXA_API_KEY` and explicit-selection public MCP fallback behavior ([#1798](https://github.com/can1357/oh-my-pi/issues/1798)).
- `omp usage` now surfaces auto-disabled credentials as red `✗` tombstone rows (identity, how long ago, the shortened upstream cause — e.g. `Refresh token expired` — and a re-login hint), including a provider section when no active credential remains. User-driven tombstones (`replaced by newer credential`, `deleted by user`) and API-key rows stay hidden. Requires a broker with `GET /v1/credentials/disabled`; older brokers degrade to no tombstone rows.
- `omp usage` warns about Anthropic's ~30-day OAuth grant lifetime: accounts whose interactive login (`authorizedAt`) is within a week of the deadline get a yellow `⚠ re-login within <time>` line, and past-deadline accounts a red one. Grants die server-side exactly ~30 days after login regardless of refresh rotation, so this is the only warning before the broker auto-disables the row.
@@ -32,7 +32,11 @@ export const SEARCH_PROVIDER_OPTIONS = [
},
{ value: "xai", label: "xAI", description: "Grok web search via xAI Responses API (requires XAI_API_KEY)" },
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
{ value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
{
value: "exa",
label: "Exa",
description: "Uses Exa API with /login exa or EXA_API_KEY; explicit selection can fall back to public Exa MCP",
},
{ value: "tinyfish", label: "TinyFish", description: "Requires TINYFISH_API_KEY" },
{ value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },