diff --git a/README.md b/README.md index 3e9fe972b..9b88aa6ab 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,8 @@ Twenty-five backends. Pin one, or let `auto` walk the chain in order. | `mojeek` | no key (browser) | | `public` | no key (all of the above, consolidated) | +Exa also accepts a stored API key through `/login exa`; explicit keyless selection uses the public MCP fallback. + ### Specialised handlers The agent gets structured content, not stripped HTML. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index f4cac96ab..7f0046b28 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -244,7 +244,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 | diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index f54eaa1bb..f14e74e6e 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -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. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d59bca953..b721a966d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -68,6 +68,7 @@ ### Added - MiniMax Token Plan accounts now report quota in `omp usage`. `GET /v1/token_plan/remains` returns one bucket per plan quota, each carrying a rolling interval window and a weekly window, so `minimax-code` surfaces real remaining percentages instead of an empty report. A model the plan does not include comes back looking like an untouched quota; those buckets are dropped from the report and named in its metadata. The mainland id `minimax-code-cn` is untouched. +- 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. diff --git a/packages/ai/src/registry/exa.ts b/packages/ai/src/registry/exa.ts new file mode 100644 index 000000000..aaa5fb70c --- /dev/null +++ b/packages/ai/src/registry/exa.ts @@ -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; diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index 63560d883..20c74bf2e 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -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"; @@ -139,6 +140,7 @@ const ALL = [ opencodeGoProvider, tavilyProvider, kagiProvider, + exaProvider, parallelProvider, ollamaProvider, ollamaCloudProvider, diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 8fde5d432..1cd31d2a9 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -691,7 +691,6 @@ type KeyResolver = string | (() => string | undefined); const LEGACY_ENV_KEYS: Record = { // 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", diff --git a/packages/ai/test/exa-login.test.ts b/packages/ai/test/exa-login.test.ts new file mode 100644 index 000000000..39770d744 --- /dev/null +++ b/packages/ai/test/exa-login.test.ts @@ -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"); + }); +}); diff --git a/packages/ai/test/provider-registry.test.ts b/packages/ai/test/provider-registry.test.ts index a23b3010f..79a8efb58 100644 --- a/packages/ai/test/provider-registry.test.ts +++ b/packages/ai/test/provider-registry.test.ts @@ -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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d3801b28c..82d056809 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -169,6 +169,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