fix(coding-agent): routed web-search OAuth bearers through withOAuthAccess

This commit is contained in:
can1357
2026-06-12 02:17:06 +02:00
parent 98fddec509
commit 503b2b7156
3 changed files with 105 additions and 71 deletions
@@ -7,7 +7,7 @@
* SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
*/
import * as os from "node:os";
import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
import { $env, readSseJson } from "@oh-my-pi/pi-utils";
@@ -287,12 +287,12 @@ async function findCodexAuth(
authStorage: AuthStorage,
sessionId: string | undefined,
signal: AbortSignal | undefined,
): Promise<{ accessToken: string; accountId: string } | null> {
): Promise<{ access: OAuthAccess; accountId: string } | null> {
const access = await authStorage.getOAuthAccess("openai-codex", sessionId, { signal });
if (!access) return null;
const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
if (!accountId) return null;
return { accessToken: access.accessToken, accountId };
return { access, accountId };
}
/**
@@ -495,8 +495,8 @@ async function callCodexSearch(
* `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI rejects.
*/
export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
const auth = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
if (!auth) {
const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
if (!seed) {
throw new Error(
"No Codex OAuth credentials found. Login with 'omp /login openai-codex' to enable Codex web search.",
);
@@ -505,42 +505,44 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
const configuredModel = getConfiguredModel();
const modelCandidates = configuredModel ? [configuredModel] : getDefaultModelCandidates();
let result:
| {
answer: string;
sources: SearchSource[];
model: string;
requestId: string;
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
}
| undefined;
let lastError: unknown;
for (let index = 0; index < modelCandidates.length; index += 1) {
const modelId = modelCandidates[index];
if (!modelId) continue;
try {
result = await callCodexSearch(auth, params.query, {
signal: params.signal,
systemPrompt: params.systemPrompt,
searchContextSize: "high",
modelId,
fetch: params.fetch,
});
break;
} catch (error) {
lastError = error;
const isLastCandidate = index === modelCandidates.length - 1;
if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
throw error;
const result = await withOAuthAccess(
params.authStorage,
"openai-codex",
async access => {
// Derive ALL auth material from the access this attempt received —
// a refreshed/rotated credential carries a different bearer and
// ChatGPT account id than the seed.
const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
if (!accountId) {
throw new Error("Codex OAuth credential is missing a ChatGPT account id");
}
}
}
const auth = { accessToken: access.accessToken, accountId };
if (!result) {
throw lastError ?? new Error("Codex search failed without returning a result");
}
let lastError: unknown;
for (let index = 0; index < modelCandidates.length; index += 1) {
const modelId = modelCandidates[index];
if (!modelId) continue;
try {
return await callCodexSearch(auth, params.query, {
signal: params.signal,
systemPrompt: params.systemPrompt,
searchContextSize: "high",
modelId,
fetch: params.fetch,
});
} catch (error) {
lastError = error;
const isLastCandidate = index === modelCandidates.length - 1;
if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
throw error;
}
}
}
throw lastError ?? new Error("Codex search failed without returning a result");
},
{ sessionId: params.sessionId, signal: params.signal, seed: seed.access },
);
let sources = result.sources;
@@ -8,7 +8,7 @@
* sibling SQLite store and never POSTs the broker sentinel to a Google token
* endpoint.
*/
import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
import {
ANTIGRAVITY_SYSTEM_INSTRUCTION,
getAntigravityUserAgent,
@@ -72,25 +72,29 @@ interface GeminiAuth {
isAntigravity: boolean;
}
/** First configured Gemini OAuth provider plus its pre-resolved access. */
interface GeminiAuthSeed {
provider: GeminiProviderId;
access: OAuthAccess;
projectId: string;
}
/**
* Walks the configured Gemini OAuth providers in deterministic order and
* returns the first one that yields a usable access token + projectId via
* {@link AuthStorage.getOAuthAccess}. AuthStorage handles refresh + broker
* routing internally; this helper never touches refresh tokens directly.
* The resolved access seeds `withOAuthAccess` so the happy path resolves once.
*/
export async function findGeminiAuth(
authStorage: AuthStorage,
sessionId: string | undefined,
signal: AbortSignal | undefined,
): Promise<GeminiAuth | null> {
): Promise<GeminiAuthSeed | null> {
for (const provider of GEMINI_PROVIDERS) {
const access = await authStorage.getOAuthAccess(provider, sessionId, { signal });
if (!access?.accessToken || !access.projectId) continue;
return {
accessToken: access.accessToken,
projectId: access.projectId,
isAntigravity: provider === "google-antigravity",
};
return { provider, access, projectId: access.projectId };
}
return null;
}
@@ -390,26 +394,42 @@ async function callGeminiSearch(
* Executes a web search using Google Gemini with Google Search grounding.
*/
export async function searchGemini(params: GeminiSearchParams): Promise<SearchResponse> {
const auth = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
if (!auth) {
const seed = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
if (!seed) {
throw new Error(
"No Gemini OAuth credentials found. Login with 'omp /login google-gemini-cli' or 'omp /login google-antigravity' to enable Gemini web search.",
);
}
const result = await callGeminiSearch(
auth,
params.query,
params.system_prompt,
params.max_output_tokens,
params.temperature,
{
google_search: params.google_search,
code_execution: params.code_execution,
url_context: params.url_context,
},
params.fetch,
params.signal,
const isAntigravity = seed.provider === "google-antigravity";
const result = await withOAuthAccess(
params.authStorage,
seed.provider,
access =>
// Derive bearer + projectId from the access this attempt received; a
// re-resolved access may omit projectId, in which case the seed's
// project is still the right tenant for the credential. The
// `fetchWithRetry` transport backoff stays INSIDE this attempt — auth
// retry wraps transport retry.
callGeminiSearch(
{
accessToken: access.accessToken,
projectId: access.projectId ?? seed.projectId,
isAntigravity,
},
params.query,
params.system_prompt,
params.max_output_tokens,
params.temperature,
{
google_search: params.google_search,
code_execution: params.code_execution,
url_context: params.url_context,
},
params.fetch,
params.signal,
),
{ sessionId: params.sessionId, signal: params.signal, seed: seed.access },
);
let sources = result.sources;
@@ -8,7 +8,7 @@
* - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask`
*/
import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
import { type AuthStorage, type FetchImpl, getEnvApiKey, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
import { $env, readSseJson } from "@oh-my-pi/pi-utils";
import type {
PerplexityMessageOutput,
@@ -43,7 +43,7 @@ type PerplexityAuth =
}
| {
type: "oauth";
token: string;
access: OAuthAccess;
}
| {
type: "cookies";
@@ -302,11 +302,11 @@ function jwtExpiryMs(token: string): number | undefined {
}
}
async function findOAuthToken(
async function findOAuthAccess(
authStorage: AuthStorage,
sessionId: string | undefined,
signal: AbortSignal | undefined,
): Promise<string | null> {
): Promise<OAuthAccess | null> {
try {
// `getOAuthAccess` returns the raw OAuth bearer only — runtime/config
// api_key overrides and stored api_key credentials are intentionally
@@ -314,12 +314,12 @@ async function findOAuthToken(
// `www.perplexity.ai` session/SSE endpoint.
const access = await authStorage.getOAuthAccess("perplexity", sessionId, { signal });
const token = access?.accessToken;
if (!token) return null;
if (!access || !token) return null;
// Trust the JWT's own `exp` claim if it has one; otherwise treat as
// non-expiring. Perplexity session JWTs commonly omit `exp`.
const jwtExpiry = jwtExpiryMs(token);
if (jwtExpiry !== undefined && jwtExpiry <= Date.now() + OAUTH_EXPIRY_BUFFER_MS) return null;
return token;
return access;
} catch {
return null;
}
@@ -339,9 +339,9 @@ async function findPerplexityAuth(
const apiKey = findApiKey();
// 2. OAuth/session bearer from AuthStorage.
const oauthToken = await findOAuthToken(authStorage, sessionId, signal);
if (oauthToken) {
return { type: "oauth", token: oauthToken };
const oauthAccess = await findOAuthAccess(authStorage, sessionId, signal);
if (oauthAccess) {
return { type: "oauth", access: oauthAccess };
}
// 3. PERPLEXITY_API_KEY env var
@@ -646,7 +646,19 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
const auth = await findPerplexityAuth(params.authStorage, params.sessionId, params.signal);
if (auth.type !== "api_key") {
const askResult = await callPerplexityAsk(auth, params);
// OAuth bearer mode routes the whole authenticated unit (the ask
// session/SSE request) through the central auth-retry policy so a 401 or
// usage-limit force-refreshes, then rotates to a sibling credential.
// Cookie/env/anonymous modes have no rotatable credential — untouched.
const askResult =
auth.type === "oauth"
? await withOAuthAccess(
params.authStorage,
"perplexity",
access => callPerplexityAsk({ type: "oauth", token: access.accessToken }, params),
{ sessionId: params.sessionId, signal: params.signal, seed: auth.access },
)
: await callPerplexityAsk(auth, params);
return applySourceLimit(
{
provider: "perplexity",