feat(coding-agent): added resolver-based auth retries to image and search tools
- Routed image-gen, inspect-image, and web search providers through `withAuth`. - Used `reuseInitialApiKey`/`createAuthStorageResolver` for force-refresh and rotate retries. - Attached HTTP status to thrown errors so the retry classifier detects retryable failures.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
- Added `ApiKeyResolver` and `ApiKey` auth helpers, including `isApiKeyResolver`, `isAuthRetryableError`, `resolveApiKeyOnce`, and `withAuth`, and exported them from the package root
|
||||
- Added support for a function-valued `apiKey` in `SimpleStreamOptions` so a single stream request can refresh or rotate credentials during retry
|
||||
- Added `forceRefresh` credential option to `AuthStorage.getApiKey` and `rotateSessionCredential` support for session-level credential rotation after auth failures
|
||||
- Added `AuthStorage.resolver(provider, options)` method that builds an `ApiKeyResolver` implementing the a/b/c auth-retry policy directly on the storage instance
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -590,36 +590,6 @@ export interface InvalidateCredentialMatchingOptions {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} backed directly by an {@link AuthStorage},
|
||||
* implementing the central a/b/c auth-retry policy for consumers that hold an
|
||||
* AuthStorage rather than a higher-level registry (e.g. web-search providers):
|
||||
*
|
||||
* - initial (`error: undefined`) → resolve the session credential.
|
||||
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
|
||||
* - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
|
||||
*/
|
||||
export function createAuthStorageResolver(
|
||||
storage: Pick<AuthStorage, "getApiKey" | "rotateSessionCredential">,
|
||||
provider: string,
|
||||
options?: { sessionId?: string; baseUrl?: string; modelId?: string },
|
||||
): ApiKeyResolver {
|
||||
const { sessionId, baseUrl, modelId } = options ?? {};
|
||||
return async ({ lastChance, error, signal }) => {
|
||||
if (error === undefined) {
|
||||
return storage.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
|
||||
}
|
||||
if (lastChance) {
|
||||
// Rotate to a sibling credential on an account constraint. No
|
||||
// retry-after handling here — `markUsageLimitReached` owns the block
|
||||
// duration and the outer retry layer owns the no-sibling wait.
|
||||
await storage.rotateSessionCredential(provider, sessionId, { error, signal });
|
||||
return storage.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
|
||||
}
|
||||
return storage.getApiKey(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
|
||||
};
|
||||
}
|
||||
|
||||
function isAbortSignalOption(
|
||||
value: InvalidateCredentialMatchingOptions | AbortSignal | undefined,
|
||||
): value is AbortSignal {
|
||||
@@ -3787,6 +3757,31 @@ export class AuthStorage {
|
||||
return hasSibling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} backed by this storage, implementing the
|
||||
* central a/b/c auth-retry policy:
|
||||
*
|
||||
* - initial (`error: undefined`) → resolve the session credential.
|
||||
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
|
||||
* - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
|
||||
*
|
||||
* Used by web-search providers and other consumers that hold an AuthStorage
|
||||
* directly (no ModelRegistry in scope).
|
||||
*/
|
||||
resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string }): ApiKeyResolver {
|
||||
const { sessionId, baseUrl, modelId } = options ?? {};
|
||||
return async ({ lastChance, error, signal }) => {
|
||||
if (error === undefined) {
|
||||
return this.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
|
||||
}
|
||||
if (lastChance) {
|
||||
await this.rotateSessionCredential(provider, sessionId, { error, signal });
|
||||
return this.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
|
||||
}
|
||||
return this.getApiKey(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Auth Broker integration ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed non-agent API operations (title and commit-message generation) to use session-aware API key resolution with refresh-first auth retries, refreshing the active credential before rotating to another account
|
||||
- Changed non-agent API operations (title and commit-message generation, image generation, web search, eval `llm()`, auto-thinking classifier, memory consolidation) to use session-aware API key resolution with auth retries via `registry.resolver()` / `authStorage.resolver()`, refreshing the active credential before rotating to another account
|
||||
- Changed image generation to wrap every provider fetch branch in `withAuth`, so 401 / usage-limit errors trigger credential force-refresh and rotation for authStorage-backed providers (OpenAI-hosted, antigravity, xai-oauth) while env-only providers (openrouter, gemini) stay single-attempt
|
||||
- Changed web-search providers using `authStorage.getApiKey` (anthropic, exa, tavily, parallel, synthetic, zai, kimi) to wrap HTTP calls in `withAuth` for automatic credential rotation on 401 / usage-limit errors
|
||||
- Changed the directory grouping for `find`, `search`, `ast_grep`, `ast_edit`, and `lsp` diagnostics from a single flat `# dir/` heading per immediate directory to a multi-level tree that folds the common path prefix into one heading. Previously every group repeated the full directory path — so results rooted outside cwd printed the absolute prefix (e.g. `/Users/me/proj/`) on every heading and nested directories were never collapsed. Now a single-child directory chain folds into one heading (`# packages/pkg/src/`, including an absolute root for out-of-cwd results), subdirectories nest one `#` deeper (`## nested/` → `### child.ts`), and each directory's own files are listed before its subdirectories. TUI hyperlink reconstruction tracks the nested directory stack across the whole output so file and code-frame links keep resolving to the correct absolute paths.
|
||||
- Changed the plan-mode approval surface from an inline transcript block plus a separate bottom selector into a single fullscreen overlay (like `/copy`) and overhauled its navigation. The overlay now renders the plan per-section through `ScrollView` (line-level ↑/↓ scroll, Shift+↑/↓ to scroll faster, PgUp/PgDn, g/G) with no stray per-line `…`, and — when the terminal is wide enough and the plan has ≥2 headings — shows a compact VS Code-style Contents sidebar (the redundant plan-title heading is omitted). Focus moves between regions with Tab/Shift+Tab (and flows at the edges: Down past the last section or the bottom of the body drops into the approval options; Up steps back), while the sidebar glows to track the scrolled section. The sidebar can fast-jump between sections, delete a section (with `u` undo), and annotate sections with feedback (`a`) that is submitted back to the model when the operator picks "Refine plan"; ←/→ always drive the model-tier slider, Enter confirms, the external-editor key opens the plan, and Esc cancels. The overlay borrows the terminal's alternate screen buffer for its lifetime (`fullscreen` overlay), so the transcript stays put on the normal screen instead of bleeding through scrollback behind the modal.
|
||||
- Changed the interactive controllers (command, MCP, selector, extension-UI, event), debug panels, and the status/error/warning helpers to render chat output through `ctx.present(...)` instead of appending to `chatContainer` and calling `ui.requestRender()` directly; transcript rebuilds dispose live blocks via `ctx.resetTranscript()` so animated blocks' timers stop on reset.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import { reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
@@ -83,7 +83,7 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Prom
|
||||
messages: [{ role: "user", content: input, timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: reuseInitialApiKey(apiKey, deps.registry, model.provider, {
|
||||
apiKey: deps.registry.resolver(model.provider, {
|
||||
sessionId: deps.sessionId,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKeyResolverRegistry, reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
import type { ApiKeyResolverRegistry } from "../config/api-key-resolver";
|
||||
import { MODEL_ROLE_IDS } from "../config/model-registry";
|
||||
import {
|
||||
type ModelLookupRegistry,
|
||||
@@ -47,7 +47,7 @@ export async function resolvePrimaryModel(
|
||||
}
|
||||
return {
|
||||
model,
|
||||
apiKey: reuseInitialApiKey(apiKey, modelRegistry, model.provider, { baseUrl: model.baseUrl }),
|
||||
apiKey: modelRegistry.resolver(model.provider, { baseUrl: model.baseUrl }),
|
||||
thinkingLevel: resolved?.thinkingLevel,
|
||||
};
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export async function resolveSmolModel(
|
||||
if (apiKey) {
|
||||
return {
|
||||
model: resolvedSmol.model,
|
||||
apiKey: reuseInitialApiKey(apiKey, modelRegistry, resolvedSmol.model.provider, {
|
||||
apiKey: modelRegistry.resolver(resolvedSmol.model.provider, {
|
||||
baseUrl: resolvedSmol.model.baseUrl,
|
||||
}),
|
||||
thinkingLevel: resolvedSmol.thinkingLevel,
|
||||
@@ -81,7 +81,7 @@ export async function resolveSmolModel(
|
||||
if (apiKey) {
|
||||
return {
|
||||
model: candidate,
|
||||
apiKey: reuseInitialApiKey(apiKey, modelRegistry, candidate.provider, { baseUrl: candidate.baseUrl }),
|
||||
apiKey: modelRegistry.resolver(candidate.provider, { baseUrl: candidate.baseUrl }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,25 +19,23 @@ export interface ApiKeyResolverRegistry {
|
||||
options?: { baseUrl?: string; forceRefresh?: boolean; signal?: AbortSignal },
|
||||
): Promise<string | undefined>;
|
||||
authStorage: Pick<AuthStorage, "rotateSessionCredential">;
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} implementing the central a/b/c auth-retry
|
||||
* policy: initial → resolve; step (b) → force-refresh same account; step (c)
|
||||
* → rotate to a sibling credential, then re-resolve.
|
||||
*
|
||||
* The resolver is stateless (safe to reuse across requests). Callers that
|
||||
* need the initial key for a guard can call `resolveApiKeyOnce(resolver)`.
|
||||
*/
|
||||
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} backed by the model registry's auth storage,
|
||||
* implementing the central a/b/c auth-retry policy for every non-agent network
|
||||
* consumer (utility completions, image generation, web search):
|
||||
*
|
||||
* - initial (`error: undefined`) → resolve the session credential (cheap; may
|
||||
* return a locally-cached not-yet-expired token).
|
||||
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential
|
||||
* (a peer/broker may have rotated its token out from under our cached copy).
|
||||
* - step (c) `lastChance` → rotate to a sibling credential (usage-limit block
|
||||
* vs credential invalidation, by error class), then re-resolve.
|
||||
*
|
||||
* Stateless: nothing is captured beyond the registry/provider/options, so the
|
||||
* same resolver is safe to reuse across attempts and requests.
|
||||
* Default implementation of {@link ApiKeyResolverRegistry.resolver}.
|
||||
* Also usable standalone for structural registries that don't carry the method.
|
||||
*/
|
||||
export function createApiKeyResolver(
|
||||
registry: ApiKeyResolverRegistry,
|
||||
registry: Pick<ApiKeyResolverRegistry, "getApiKeyForProvider" | "authStorage">,
|
||||
provider: string,
|
||||
options: ApiKeyResolverOptions = {},
|
||||
): ApiKeyResolver {
|
||||
@@ -58,20 +56,3 @@ export function createApiKeyResolver(
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, forceRefresh: true, signal });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an already-resolved `initialKey` in an {@link ApiKeyResolver}: the
|
||||
* initial step returns that key verbatim (so a caller that resolved it eagerly
|
||||
* — e.g. to preserve a metadata / guard ordering, or to short-circuit when no
|
||||
* credential exists — does not pay for a second resolve), while retry steps
|
||||
* fall through to {@link createApiKeyResolver}'s force-refresh / rotate policy.
|
||||
*/
|
||||
export function reuseInitialApiKey(
|
||||
initialKey: string | undefined,
|
||||
registry: ApiKeyResolverRegistry,
|
||||
provider: string,
|
||||
options: ApiKeyResolverOptions = {},
|
||||
): ApiKeyResolver {
|
||||
const retry = createApiKeyResolver(registry, provider, options);
|
||||
return ctx => (ctx.error === undefined ? initialKey : retry(ctx));
|
||||
}
|
||||
|
||||
@@ -95,12 +95,14 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
|
||||
...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
|
||||
];
|
||||
|
||||
import type { ApiKeyResolver } from "@oh-my-pi/pi-ai";
|
||||
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
|
||||
import { isRecord, logger } from "@oh-my-pi/pi-utils";
|
||||
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
|
||||
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
||||
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
|
||||
import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
|
||||
import { type ConfigError, ConfigFile } from "./config-file";
|
||||
import {
|
||||
buildCanonicalModelIndex,
|
||||
@@ -2385,6 +2387,15 @@ export class ModelRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} for this provider, implementing the
|
||||
* central a/b/c auth-retry policy. Callers that need the initial key for
|
||||
* a guard can call `resolveApiKeyOnce(resolver)`.
|
||||
*/
|
||||
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver {
|
||||
return createApiKeyResolver(this, provider, options);
|
||||
}
|
||||
|
||||
async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
|
||||
if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
|
||||
return kNoAuth;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent
|
||||
import { type Api, Effort, getSupportedEfforts, type Model, type Tool } from "@oh-my-pi/pi-ai";
|
||||
import * as z from "zod/v4";
|
||||
import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils";
|
||||
import { reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
|
||||
import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
|
||||
import type { ToolSession } from "../tools";
|
||||
import { ToolError } from "../tools/tool-errors";
|
||||
@@ -145,7 +145,7 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
tools,
|
||||
},
|
||||
{
|
||||
apiKey: reuseInitialApiKey(apiKey, registry, model.provider, {
|
||||
apiKey: registry.resolver(model.provider, {
|
||||
sessionId: options.session.getSessionId?.() ?? undefined,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as path from "node:path";
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
@@ -272,7 +272,7 @@ async function runPhase1(options: {
|
||||
const result = await runStage1Job({
|
||||
claim,
|
||||
model: phase1Model,
|
||||
apiKey: reuseInitialApiKey(phase1ApiKey, modelRegistry, phase1Model.provider, {
|
||||
apiKey: modelRegistry.resolver(phase1Model.provider, {
|
||||
sessionId: session.sessionId,
|
||||
baseUrl: phase1Model.baseUrl,
|
||||
}),
|
||||
@@ -432,7 +432,7 @@ async function runPhase2(options: {
|
||||
const consolidated = await runConsolidationModel({
|
||||
memoryRoot,
|
||||
model: phase2Model,
|
||||
apiKey: reuseInitialApiKey(phase2ApiKey, modelRegistry, phase2Model.provider, {
|
||||
apiKey: modelRegistry.resolver(phase2Model.provider, {
|
||||
sessionId: session.sessionId,
|
||||
baseUrl: phase2Model.baseUrl,
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Mnemopi } from "@oh-my-pi/pi-mnemopi";
|
||||
import { BankManager } from "@oh-my-pi/pi-mnemopi/core";
|
||||
import { type DiagnosticSummary, inspectDatabase } from "@oh-my-pi/pi-mnemopi/diagnose";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types";
|
||||
@@ -335,7 +335,7 @@ async function resolveMnemopiProviderOptions(
|
||||
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: reuseInitialApiKey(apiKey, modelRegistry, model.provider, {
|
||||
apiKey: modelRegistry.resolver(model.provider, {
|
||||
sessionId,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getAntigravityUserAgent, getEnvApiKey, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, getAntigravityUserAgent, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
CODEX_BASE_URL,
|
||||
getCodexAccountId,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import * as z from "zod/v4";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
|
||||
import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
|
||||
import type { CustomTool } from "../extensibility/custom-tools/types";
|
||||
import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
|
||||
@@ -864,7 +865,10 @@ async function generateOpenAIHostedImage(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenAI image request failed (${response.status}): ${getOpenAIResponseErrorMessage(errorText)}`);
|
||||
throw Object.assign(
|
||||
new Error(`OpenAI image request failed (${response.status}): ${getOpenAIResponseErrorMessage(errorText)}`),
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
@@ -1037,13 +1041,16 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
||||
throw new Error("Missing active GPT model for OpenAI image generation");
|
||||
}
|
||||
|
||||
const parsed = await generateOpenAIHostedImage(
|
||||
apiKey.apiKey,
|
||||
apiKey.model,
|
||||
params,
|
||||
resolvedImages,
|
||||
requestSignal,
|
||||
const hostedModel = apiKey.model;
|
||||
const hostedKey: ApiKey = ctx.modelRegistry.resolver(hostedModel.provider, {
|
||||
sessionId,
|
||||
baseUrl: hostedModel.baseUrl,
|
||||
});
|
||||
|
||||
const parsed = await withAuth(
|
||||
hostedKey,
|
||||
key => generateOpenAIHostedImage(key, hostedModel, params, resolvedImages, requestSignal, sessionId),
|
||||
{ signal: requestSignal },
|
||||
);
|
||||
|
||||
if (parsed.images.length === 0) {
|
||||
@@ -1088,38 +1095,57 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
||||
}
|
||||
|
||||
const prompt = assemblePrompt(params);
|
||||
const requestBody = buildAntigravityRequest(
|
||||
prompt,
|
||||
model,
|
||||
apiKey.projectId,
|
||||
params.aspect_ratio,
|
||||
params.image_size,
|
||||
resolvedImages,
|
||||
);
|
||||
|
||||
const response = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"User-Agent": getAntigravityUserAgent(),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: requestSignal,
|
||||
const antigravityKey: ApiKey = ctx.modelRegistry.resolver("google-antigravity", {
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let message = errorText;
|
||||
try {
|
||||
const parsed = JSON.parse(errorText) as { error?: { message?: string } };
|
||||
message = parsed.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
throw new Error(`Antigravity image request failed (${response.status}): ${message}`);
|
||||
}
|
||||
const response = await withAuth(
|
||||
antigravityKey,
|
||||
async key => {
|
||||
// On a retry the resolver yields the raw stored credential JSON
|
||||
// ({ token, projectId }); the initial seed is the already-parsed
|
||||
// access token. Tolerate both, falling back to the seed projectId.
|
||||
const rotated = parseAntigravityCredentials(key);
|
||||
const bearer = rotated?.accessToken ?? key;
|
||||
const projectId = rotated?.projectId ?? apiKey.projectId!;
|
||||
const requestBody = buildAntigravityRequest(
|
||||
prompt,
|
||||
model,
|
||||
projectId,
|
||||
params.aspect_ratio,
|
||||
params.image_size,
|
||||
resolvedImages,
|
||||
);
|
||||
|
||||
const resp = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"User-Agent": getAntigravityUserAgent(),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: requestSignal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
let message = errorText;
|
||||
try {
|
||||
const parsedErr = JSON.parse(errorText) as { error?: { message?: string } };
|
||||
message = parsedErr.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
throw Object.assign(new Error(`Antigravity image request failed (${resp.status}): ${message}`), {
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
{ signal: requestSignal },
|
||||
);
|
||||
|
||||
const parsed = await parseAntigravitySseForImage(response, requestSignal);
|
||||
const responseText = parsed.text.length > 0 ? parsed.text.join(" ") : undefined;
|
||||
@@ -1191,28 +1217,41 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
||||
: xaiBaseBody;
|
||||
const xaiEndpoint = isEdit ? "/images/edits" : "/images/generations";
|
||||
|
||||
const xaiResponse = await fetch(`${xaiCreds.baseURL}${xaiEndpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${xaiCreds.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ohMyPiXAIUserAgent(),
|
||||
},
|
||||
body: JSON.stringify(xaiBody),
|
||||
signal: requestSignal,
|
||||
const xaiKey: ApiKey = ctx.modelRegistry.resolver(xaiCreds.provider, {
|
||||
sessionId,
|
||||
baseUrl: xaiCreds.baseURL,
|
||||
});
|
||||
|
||||
const xaiRawText = await xaiResponse.text();
|
||||
if (!xaiResponse.ok) {
|
||||
let message = xaiRawText;
|
||||
try {
|
||||
const parsedErr = JSON.parse(xaiRawText) as { error?: { message?: string } };
|
||||
message = parsedErr.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
throw new Error(`xAI image request failed (${xaiResponse.status}): ${message}`);
|
||||
}
|
||||
const xaiRawText = await withAuth(
|
||||
xaiKey,
|
||||
async key => {
|
||||
const resp = await fetch(`${xaiCreds.baseURL}${xaiEndpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ohMyPiXAIUserAgent(),
|
||||
},
|
||||
body: JSON.stringify(xaiBody),
|
||||
signal: requestSignal,
|
||||
});
|
||||
const rawText = await resp.text();
|
||||
if (!resp.ok) {
|
||||
let message = rawText;
|
||||
try {
|
||||
const parsedErr = JSON.parse(rawText) as { error?: { message?: string } };
|
||||
message = parsedErr.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
throw Object.assign(new Error(`xAI image request failed (${resp.status}): ${message}`), {
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
return rawText;
|
||||
},
|
||||
{ signal: requestSignal },
|
||||
);
|
||||
|
||||
const xaiData = JSON.parse(xaiRawText) as {
|
||||
data?: Array<{ b64_json?: string; url?: string }>;
|
||||
@@ -1269,30 +1308,34 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
||||
messages: [{ role: "user" as const, content: contentParts }],
|
||||
};
|
||||
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey.apiKey}`,
|
||||
"HTTP-Referer": "https://omp.sh/",
|
||||
"X-OpenRouter-Title": "Oh-My-Pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: requestSignal,
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = rawText;
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as { error?: { message?: string } };
|
||||
message = parsed.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
const rawText = await withAuth(apiKey.apiKey, async key => {
|
||||
const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
"HTTP-Referer": "https://omp.sh/",
|
||||
"X-OpenRouter-Title": "Oh-My-Pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: requestSignal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
let message = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: { message?: string } };
|
||||
message = parsed.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
throw Object.assign(new Error(`OpenRouter image request failed (${resp.status}): ${message}`), {
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
throw new Error(`OpenRouter image request failed (${response.status}): ${message}`);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
const data = JSON.parse(rawText) as OpenRouterResponse;
|
||||
const message = data.choices?.[0]?.message;
|
||||
@@ -1360,30 +1403,34 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
||||
generationConfig,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": apiKey.apiKey,
|
||||
const rawText = await withAuth(apiKey.apiKey, async key => {
|
||||
const resp = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": key,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: requestSignal,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: requestSignal,
|
||||
},
|
||||
);
|
||||
|
||||
const rawText = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = rawText;
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as { error?: { message?: string } };
|
||||
message = parsed.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
let message = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: { message?: string } };
|
||||
message = parsed.error?.message ?? message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
throw Object.assign(new Error(`Gemini image request failed (${resp.status}): ${message}`), {
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
throw new Error(`Gemini image request failed (${response.status}): ${message}`);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
const data = JSON.parse(rawText) as GeminiGenerateContentResponse;
|
||||
const responseParts = combineParts(data);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import * as z from "zod/v4";
|
||||
import { extractTextContent } from "../commit/utils";
|
||||
|
||||
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
|
||||
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
|
||||
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
|
||||
@@ -136,7 +137,13 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
|
||||
},
|
||||
],
|
||||
},
|
||||
{ apiKey, signal },
|
||||
{
|
||||
apiKey: modelRegistry.resolver(model.provider, {
|
||||
sessionId: this.session.getSessionId?.() ?? undefined,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
{ telemetry, oneshotKind: "inspect_image", completeImpl: this.completeImageRequest },
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
@@ -112,7 +112,7 @@ export async function generateCommitMessage(
|
||||
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey: reuseInitialApiKey(apiKey, registry, candidate.model.provider, {
|
||||
apiKey: registry.resolver(candidate.model.provider, {
|
||||
sessionId,
|
||||
baseUrl: candidate.model.baseUrl,
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as path from "node:path";
|
||||
import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
|
||||
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { reuseInitialApiKey } from "../config/api-key-resolver";
|
||||
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
||||
@@ -239,7 +239,7 @@ export async function generateTitleOnline(
|
||||
tools: [setTitleTool],
|
||||
},
|
||||
{
|
||||
apiKey: reuseInitialApiKey(apiKey, registry, model.provider, { sessionId, baseUrl: model.baseUrl }),
|
||||
apiKey: registry.resolver(model.provider, { sessionId, baseUrl: model.baseUrl }),
|
||||
maxTokens,
|
||||
disableReasoning: true,
|
||||
toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
import {
|
||||
type AnthropicAuthConfig,
|
||||
type AnthropicSystemBlock,
|
||||
type ApiKey,
|
||||
type AuthStorage,
|
||||
buildAnthropicAuthConfig,
|
||||
buildAnthropicSearchHeaders,
|
||||
buildAnthropicSystemBlocks,
|
||||
buildAnthropicUrl,
|
||||
stripClaudeToolPrefix,
|
||||
withAuth,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import type {
|
||||
@@ -247,18 +249,13 @@ export async function searchAnthropic(
|
||||
): Promise<SearchResponse> {
|
||||
const searchApiKey = $env.ANTHROPIC_SEARCH_API_KEY;
|
||||
const searchBaseUrl = $env.ANTHROPIC_SEARCH_BASE_URL;
|
||||
let auth: AnthropicAuthConfig | undefined;
|
||||
const keyOrResolver: ApiKey | undefined = searchApiKey
|
||||
? searchApiKey
|
||||
: "authStorage" in params
|
||||
? params.authStorage.resolver("anthropic", { sessionId: params.sessionId })
|
||||
: undefined;
|
||||
|
||||
if (searchApiKey) {
|
||||
auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
|
||||
} else if ("authStorage" in params) {
|
||||
const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
|
||||
signal: params.signal,
|
||||
});
|
||||
if (apiKey) auth = buildAnthropicAuthConfig(apiKey, searchBaseUrl);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
if (!keyOrResolver) {
|
||||
throw new Error(
|
||||
"No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
|
||||
);
|
||||
@@ -267,14 +264,23 @@ export async function searchAnthropic(
|
||||
const model = getModel();
|
||||
const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
|
||||
const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
|
||||
const response = await callSearch(
|
||||
auth,
|
||||
model,
|
||||
params.query,
|
||||
systemPrompt,
|
||||
maxTokens,
|
||||
params.temperature,
|
||||
params.signal,
|
||||
const response = await withAuth(
|
||||
keyOrResolver,
|
||||
key =>
|
||||
callSearch(
|
||||
buildAnthropicAuthConfig(key, searchBaseUrl),
|
||||
model,
|
||||
params.query,
|
||||
systemPrompt,
|
||||
maxTokens,
|
||||
params.temperature,
|
||||
params.signal,
|
||||
),
|
||||
{
|
||||
signal: params.signal,
|
||||
missingKeyMessage:
|
||||
"No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
|
||||
},
|
||||
);
|
||||
|
||||
const result = parseResponse(response);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Requests per-result summaries via `contents.summary` and synthesizes
|
||||
* them into a combined `answer` string on the SearchResponse.
|
||||
*/
|
||||
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import { settings } from "../../../config/settings";
|
||||
import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
|
||||
|
||||
@@ -228,11 +228,19 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
|
||||
|
||||
/** Execute Exa web search */
|
||||
export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
|
||||
// AuthStorage-backed key takes precedence (existing behavior); probe it once
|
||||
// so the env-key and keyless-MCP fallbacks below stay intact, then drive the
|
||||
// authStorage path through the central force-refresh/rotate retry policy.
|
||||
const storedKey = params.authStorage
|
||||
? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
|
||||
: undefined;
|
||||
const apiKey = storedKey ?? getEnvApiKey("exa");
|
||||
const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params);
|
||||
const keyOrResolver: ApiKey | undefined =
|
||||
storedKey && params.authStorage
|
||||
? params.authStorage.resolver("exa", { sessionId: params.sessionId })
|
||||
: getEnvApiKey("exa");
|
||||
const response = keyOrResolver
|
||||
? await withAuth(keyOrResolver, key => callExaSearch(key, params), { signal: params.signal })
|
||||
: await callExaMcpSearch(params);
|
||||
|
||||
// Convert to unified SearchResponse
|
||||
const sources: SearchSource[] = [];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Uses Moonshot Kimi Code search API to retrieve web results.
|
||||
* Endpoint: POST https://api.kimi.com/coding/v1/search
|
||||
*/
|
||||
import type { AuthStorage } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, type AuthStorage, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
@@ -54,20 +54,26 @@ function resolveBaseUrl(): string {
|
||||
return asTrimmed($env.MOONSHOT_SEARCH_BASE_URL) ?? asTrimmed($env.KIMI_SEARCH_BASE_URL) ?? KIMI_SEARCH_URL;
|
||||
}
|
||||
|
||||
/** Find Kimi search credentials from environment or AuthStorage. */
|
||||
async function findApiKey(
|
||||
/**
|
||||
* Resolve the Kimi search credential. Highest precedence is the static env key;
|
||||
* otherwise an AuthStorage-backed resolver for whichever stored provider id
|
||||
* holds a key (`moonshot` first, then `kimi-code`), so a stale token triggers
|
||||
* the central force-refresh / sibling-rotate retry. Returns `undefined` when
|
||||
* neither is configured.
|
||||
*/
|
||||
async function resolveKey(
|
||||
authStorage: AuthStorage,
|
||||
sessionId: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string | null> {
|
||||
): Promise<ApiKey | undefined> {
|
||||
const envKey = asTrimmed($env.MOONSHOT_SEARCH_API_KEY) ?? asTrimmed($env.KIMI_SEARCH_API_KEY);
|
||||
if (envKey) return envKey;
|
||||
|
||||
return (
|
||||
(await authStorage.getApiKey("moonshot", sessionId, { signal })) ??
|
||||
(await authStorage.getApiKey("kimi-code", sessionId, { signal })) ??
|
||||
null
|
||||
);
|
||||
for (const provider of ["moonshot", "kimi-code"] as const) {
|
||||
const stored = await authStorage.getApiKey(provider, sessionId, { signal });
|
||||
if (stored) return authStorage.resolver(provider, { sessionId });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function callKimiSearch(
|
||||
@@ -108,20 +114,25 @@ async function callKimiSearch(
|
||||
|
||||
/** Execute Kimi web search. */
|
||||
export async function searchKimi(params: KimiSearchParams): Promise<SearchResponse> {
|
||||
const apiKey = await findApiKey(params.authStorage, params.sessionId, params.signal);
|
||||
if (!apiKey) {
|
||||
const keyOrResolver = await resolveKey(params.authStorage, params.sessionId, params.signal);
|
||||
if (!keyOrResolver) {
|
||||
throw new Error(
|
||||
"Kimi search credentials not found. Set MOONSHOT_SEARCH_API_KEY, KIMI_SEARCH_API_KEY, MOONSHOT_API_KEY, or login with 'omp /login moonshot'.",
|
||||
);
|
||||
}
|
||||
|
||||
const limit = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
||||
const { response, requestId } = await callKimiSearch(apiKey, {
|
||||
query: params.query,
|
||||
limit,
|
||||
includeContent: params.include_content ?? false,
|
||||
signal: params.signal,
|
||||
});
|
||||
const { response, requestId } = await withAuth(
|
||||
keyOrResolver,
|
||||
key =>
|
||||
callKimiSearch(key, {
|
||||
query: params.query,
|
||||
limit,
|
||||
includeContent: params.include_content ?? false,
|
||||
signal: params.signal,
|
||||
}),
|
||||
{ signal: params.signal },
|
||||
);
|
||||
const sources: SearchSource[] = [];
|
||||
|
||||
for (const result of response.search_results ?? []) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import type { SearchResponse } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import { ParallelApiError, type ParallelSearchResult, type ParallelSearchSource } from "../../parallel";
|
||||
@@ -123,30 +123,41 @@ async function searchWithAuthStorage(
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(PARALLEL_SEARCH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"parallel-beta": PARALLEL_BETA_HEADER,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
objective,
|
||||
search_queries: queries,
|
||||
mode: "fast",
|
||||
excerpts: {
|
||||
max_chars_per_result: 10_000,
|
||||
},
|
||||
}),
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw parseParallelErrorResponse(response.status, await response.text());
|
||||
}
|
||||
// Drive the (already-present) credential through the central force-refresh /
|
||||
// sibling-rotate retry policy. The `ParallelApiError` thrown below carries a
|
||||
// `statusCode`, which `withAuth`'s default classifier reads to detect a
|
||||
// retryable 401 / usage-limit.
|
||||
const keyOrResolver: ApiKey = authStorage.resolver("parallel", { sessionId });
|
||||
return withAuth(
|
||||
keyOrResolver,
|
||||
async key => {
|
||||
const response = await fetch(PARALLEL_SEARCH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": key,
|
||||
"parallel-beta": PARALLEL_BETA_HEADER,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
objective,
|
||||
search_queries: queries,
|
||||
mode: "fast",
|
||||
excerpts: {
|
||||
max_chars_per_result: 10_000,
|
||||
},
|
||||
}),
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw parseParallelErrorResponse(response.status, await response.text());
|
||||
}
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
return parseSearchPayload(payload);
|
||||
const payload: unknown = await response.json();
|
||||
return parseSearchPayload(payload);
|
||||
},
|
||||
{ signal: params.signal },
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchParallel(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Endpoint: POST https://api.synthetic.new/v2/search
|
||||
*/
|
||||
|
||||
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
@@ -66,12 +66,14 @@ async function callSyntheticSearch(
|
||||
|
||||
/** Execute Synthetic web search. */
|
||||
export async function searchSynthetic(params: SearchParams): Promise<SearchResponse> {
|
||||
const apiKey = await findApiKey(params.authStorage, params.sessionId, params.signal);
|
||||
if (!apiKey) {
|
||||
throw new Error("Synthetic credentials not found. Set SYNTHETIC_API_KEY or login with 'omp /login synthetic'.");
|
||||
}
|
||||
const keyOrResolver: ApiKey = params.authStorage.resolver("synthetic", {
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
const data = await callSyntheticSearch(apiKey, params.query, params.signal);
|
||||
const data = await withAuth(keyOrResolver, key => callSyntheticSearch(key, params.query, params.signal), {
|
||||
signal: params.signal,
|
||||
missingKeyMessage: "Synthetic credentials not found. Set SYNTHETIC_API_KEY or login with 'omp /login synthetic'.",
|
||||
});
|
||||
const sources: SearchSource[] = [];
|
||||
|
||||
for (const result of data.results ?? []) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Uses Tavily's agent-focused search API to return structured results with an
|
||||
* optional synthesized answer.
|
||||
*/
|
||||
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
||||
@@ -127,15 +127,16 @@ export async function searchTavily(params: SearchParams): Promise<SearchResponse
|
||||
recency: params.recency,
|
||||
signal: params.signal,
|
||||
};
|
||||
const apiKey = await findApiKey(params.authStorage, params.sessionId, params.signal);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'Tavily credentials not found. Set TAVILY_API_KEY or configure an API key for provider "tavily".',
|
||||
);
|
||||
}
|
||||
const keyOrResolver: ApiKey = params.authStorage.resolver("tavily", {
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
const numResults = clampNumResults(tavilyParams.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
||||
const response = await callTavilySearch(apiKey, tavilyParams);
|
||||
const response = await withAuth(keyOrResolver, key => callTavilySearch(key, tavilyParams), {
|
||||
signal: params.signal,
|
||||
missingKeyMessage:
|
||||
'Tavily credentials not found. Set TAVILY_API_KEY or configure an API key for provider "tavily".',
|
||||
});
|
||||
const sources: SearchSource[] = [];
|
||||
|
||||
for (const result of response.results ?? []) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Calls Z.AI's remote MCP server (`webSearchPrime`) and adapts results into
|
||||
* the unified SearchResponse shape used by the web search tool.
|
||||
*/
|
||||
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
||||
import { asRecord, asString } from "../../../web/scrapers/utils";
|
||||
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
@@ -278,12 +278,14 @@ function toSources(results: ZaiSearchResult[]): SearchSource[] {
|
||||
|
||||
/** Execute Z.AI web search via remote MCP endpoint. */
|
||||
export async function searchZai(params: ZaiSearchParams): Promise<SearchResponse> {
|
||||
const apiKey = await findApiKey(params.authStorage, params.sessionId, params.signal);
|
||||
if (!apiKey) {
|
||||
throw new Error("Z.AI credentials not found. Set ZAI_API_KEY or login with 'omp /login zai'.");
|
||||
}
|
||||
const keyOrResolver: ApiKey = params.authStorage.resolver("zai", {
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
const rawResult = await callZaiSearch(apiKey, params);
|
||||
const rawResult = await withAuth(keyOrResolver, key => callZaiSearch(key, params), {
|
||||
signal: params.signal,
|
||||
missingKeyMessage: "Z.AI credentials not found. Set ZAI_API_KEY or login with 'omp /login zai'.",
|
||||
});
|
||||
const payload = parseSearchPayload(rawResult);
|
||||
let sources = toSources(payload.results);
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ describe("commit role thinking selection", () => {
|
||||
getAvailable: () => [defaultModel, commitModel],
|
||||
getApiKey: async () => "test-key",
|
||||
getApiKeyForProvider: async () => "test-key",
|
||||
authStorage: { rotateSessionCredential: async () => false },
|
||||
authStorage: { rotateSessionCredential: async () => false as const },
|
||||
resolver: () => async () => "test-key",
|
||||
};
|
||||
|
||||
const primary = await resolvePrimaryModel(undefined, settings, registry);
|
||||
|
||||
@@ -29,6 +29,9 @@ function createRegistry(model: Model<Api>) {
|
||||
return {
|
||||
getAvailable: () => [model],
|
||||
getApiKey: async () => "test-key",
|
||||
getApiKeyForProvider: async () => "test-key",
|
||||
authStorage: { rotateSessionCredential: async () => false },
|
||||
resolver: () => async () => "test-key",
|
||||
} as never;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ describe("imageGenTool", () => {
|
||||
modelRegistry: {
|
||||
getApiKey: async () => "test-openai-key",
|
||||
getApiKeyForProvider: async () => undefined,
|
||||
authStorage: { rotateSessionCredential: async () => false },
|
||||
resolver: () => async () => "test-openai-key",
|
||||
} as unknown as ModelRegistry,
|
||||
model,
|
||||
isIdle: () => true,
|
||||
@@ -126,7 +128,9 @@ describe("imageGenTool", () => {
|
||||
getAll: () => [],
|
||||
authStorage: {
|
||||
hasNonEnvCredential: (provider: string) => provider === "xai-oauth",
|
||||
rotateSessionCredential: async () => false,
|
||||
},
|
||||
resolver: () => async () => "test-xai-token",
|
||||
} as unknown as ModelRegistry,
|
||||
model: undefined,
|
||||
isIdle: () => true,
|
||||
|
||||
@@ -68,6 +68,9 @@ function createSession(
|
||||
modelRegistry: {
|
||||
getAvailable: () => availableModels,
|
||||
getApiKey: async () => apiKey,
|
||||
getApiKeyForProvider: async () => apiKey,
|
||||
authStorage: { rotateSessionCredential: async () => false },
|
||||
resolver: () => async () => apiKey,
|
||||
} as unknown as NonNullable<ToolSession["modelRegistry"]>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("searchToolRenderer", () => {
|
||||
},
|
||||
);
|
||||
const renderedLines = sanitizeText(collapsed.render(200).join("\n")).split("\n");
|
||||
const bodyLines = renderedLines.slice(1);
|
||||
const bodyLines = renderedLines.slice(1, -1);
|
||||
|
||||
expect(renderedLines[0]).toContain("truncated");
|
||||
expect(bodyLines).toHaveLength(6);
|
||||
@@ -93,7 +93,7 @@ describe("searchToolRenderer", () => {
|
||||
{ pattern: "Flag" },
|
||||
);
|
||||
const renderedLines = sanitizeText(collapsed.render(240).join("\n")).split("\n");
|
||||
const bodyLines = renderedLines.slice(1);
|
||||
const bodyLines = renderedLines.slice(1, -1);
|
||||
|
||||
expect(bodyLines).toHaveLength(6);
|
||||
expect(bodyLines.some(line => line.includes("const firstFlag = true;"))).toBe(true);
|
||||
|
||||
@@ -30,6 +30,12 @@ describe("Parallel web search", () => {
|
||||
hasAuth() {
|
||||
return Boolean(process.env.PARALLEL_API_KEY);
|
||||
},
|
||||
resolver(_provider: string) {
|
||||
return async () => process.env.PARALLEL_API_KEY ?? undefined;
|
||||
},
|
||||
async rotateSessionCredential() {
|
||||
return false;
|
||||
},
|
||||
} as unknown as AuthStorage;
|
||||
|
||||
let capturedRequestBody: unknown;
|
||||
|
||||
@@ -21,6 +21,12 @@ describe("Tavily web search provider", () => {
|
||||
hasAuth() {
|
||||
return Boolean(process.env.TAVILY_API_KEY);
|
||||
},
|
||||
resolver(_provider: string) {
|
||||
return async () => process.env.TAVILY_API_KEY ?? undefined;
|
||||
},
|
||||
async rotateSessionCredential() {
|
||||
return false;
|
||||
},
|
||||
} as unknown as AuthStorage;
|
||||
|
||||
function makeParams(query: string) {
|
||||
|
||||
Reference in New Issue
Block a user