fix(security): sanitize error messages, fix ReDoS patterns, harden OAuth callback

Error message sanitization (Hard Rule #12):
- claude-auth/export, codex-auth/export, gemini-cli-auth/export routes: replace
  raw err.message with sanitizeErrorMessage() from open-sse/utils/error.ts
- imageGeneration, musicGeneration, videoGeneration handlers: import
  sanitizeErrorMessage and replace all err.message in return values
- veoaifree-web executor: replace raw upstream response data in errResp() calls
  with static strings

OAuth callback page (callback/page.tsx):
- Remove useSearchParams/Suspense dependency that caused hydration failures in
  popup windows navigating back from Google OAuth (COOP header severs opener)
- Use window.location.search directly in useEffect with three send methods:
  postMessage, BroadcastChannel, localStorage
- Fix postMessage target from "*" to window.location.origin (semgrep finding)
- Move setCurrentUrl call to manual-only branch to avoid unnecessary renders

copilot-web executor:
- Move accessToken from WebSocket URL query string to Authorization header
  (avoids credential exposure in server logs)
- Add MAX_POOL_SIZE=100 cap to sessionPool with LRU eviction of oldest entry

CodeQL ReDoS fixes (js/polynomial-redos #233-240):
- Replace while(s.endsWith("/")) s=s.slice(0,-1) pattern (O(n²) allocations)
  with index-based loop (O(n) time, single final slice) in:
  bin/cli/api.mjs, all 6 cli-helper config generators, opencode-provider

Gemini OAuth:
- mapTokens: add idToken field to fix "missing id_token" export error
This commit is contained in:
diegosouzapw
2026-05-18 17:42:09 -03:00
parent 72900bead3
commit 04d44f6262
19 changed files with 185 additions and 115 deletions

View File

@@ -193,7 +193,9 @@ export function normalizeBaseURL(rawBaseURL: string): string {
);
}
let base = trimmed;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
return base + "/v1";
}

View File

@@ -45,9 +45,10 @@ export function getBaseUrl(opts = {}) {
}
function stripTrailingSlash(value) {
let s = String(value);
while (s.endsWith("/")) s = s.slice(0, -1);
return s;
const s = String(value);
let end = s.length;
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
return end === s.length ? s : s.slice(0, end);
}
function resolveUrl(path, opts) {

View File

@@ -117,6 +117,7 @@ const sessionPool = new Map<string, CopilotSession>();
let sessionRotationCount = 0;
const MIN_REMAINING_TURNS = 5;
const MAX_ROTATIONS = 1000;
const MAX_POOL_SIZE = 100;
// ─── Executor ───────────────────────────────────────────────────────────────
@@ -150,6 +151,10 @@ export class CopilotWebExecutor extends BaseExecutor {
}
const session = await this.createSession(accessToken, signal);
// Evict oldest entry if pool is at capacity (Map preserves insertion order)
if (sessionPool.size >= MAX_POOL_SIZE) {
sessionPool.delete(sessionPool.keys().next().value!);
}
sessionPool.set(poolKey, session);
sessionRotationCount++;
return session;
@@ -214,11 +219,8 @@ export class CopilotWebExecutor extends BaseExecutor {
accessToken?: string,
signal?: AbortSignal
): Promise<ReadableStream<Uint8Array>> {
// Build WebSocket URL with optional auth
let wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`;
if (accessToken) {
wsUrl += `&accessToken=${encodeURIComponent(accessToken)}`;
}
// Build WebSocket URL without credentials in query string
const wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`;
return new ReadableStream({
start: async (controller) => {
@@ -261,13 +263,23 @@ export class CopilotWebExecutor extends BaseExecutor {
signal?.addEventListener("abort", () => abort("Request aborted"), { once: true });
try {
// Use Node.js built-in WebSocket if available, else dynamic import
// Use Node.js built-in WebSocket if available, else dynamic import.
// Pass the access token via Authorization header (not URL) to avoid
// credential exposure in server logs.
let WS = globalThis.WebSocket;
if (!WS) {
// @ts-ignore — ws module has no type declarations in this project
WS = (await import("ws")).default as unknown as typeof WebSocket;
if (accessToken) {
// @ts-ignore — ws module supports headers option in second arg
ws = new WS(wsUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
}) as WebSocket;
}
}
if (!ws) {
ws = new WS(wsUrl) as WebSocket;
}
ws = new WS(wsUrl) as WebSocket;
const timeout = setTimeout(() => abort("Copilot WebSocket timeout"), FETCH_TIMEOUT_MS);

View File

@@ -82,7 +82,7 @@ async function handleVideo(nonce: string, prompt: string, aspectRatio: string):
});
const sceneData = genResult.trim();
if (!sceneData || sceneData === "0" || sceneData.toLowerCase().includes("error")) {
return errResp(`Video generation failed: ${sceneData}`);
return errResp("Video generation failed");
}
// Poll
@@ -123,7 +123,7 @@ async function handleImage(nonce: string, prompt: string, aspectRatio: string):
});
const trimmed = result.trim();
if (!trimmed || trimmed === "0" || trimmed.toLowerCase().includes("error")) {
return errResp(`Image generation failed: ${trimmed}`);
return errResp("Image generation failed");
}
// Response is comma-separated base64 PNGs or URLs
const parts = trimmed
@@ -191,7 +191,7 @@ async function handleTTS(prompt: string, voice?: string, lang?: string): Promise
} catch {
/* not JSON */
}
return errResp(`TTS unexpected response: ${data.slice(0, 200)}`);
return errResp("TTS unexpected response format");
}
async function handleEnhance(nonce: string, prompt: string): Promise<Response> {

View File

@@ -38,6 +38,7 @@ import {
extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import { sanitizeErrorMessage } from "../utils/error.ts";
interface KieImageOptions {
model: string;
@@ -722,7 +723,11 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -1301,7 +1306,7 @@ async function handleFalAIImageGeneration({
model,
status: 502,
startTime,
error: `Image provider error: ${err.message}`,
error: sanitizeErrorMessage(err) || "Image provider error",
});
}
}
@@ -1491,7 +1496,7 @@ async function handleStabilityAIImageGeneration({
model,
status: 502,
startTime,
error: `Image provider error: ${err.message}`,
error: sanitizeErrorMessage(err) || "Image provider error",
});
}
}
@@ -1610,7 +1615,7 @@ async function handleBlackForestLabsImageGeneration({
model,
status: 502,
startTime,
error: `Image provider error: ${err.message}`,
error: sanitizeErrorMessage(err) || "Image provider error",
});
}
}
@@ -1685,7 +1690,7 @@ async function handleRecraftImageGeneration({
model,
status: 502,
startTime,
error: `Image provider error: ${err.message}`,
error: sanitizeErrorMessage(err) || "Image provider error",
});
}
}
@@ -1773,7 +1778,7 @@ async function handleTopazImageGeneration({
model,
status: 502,
startTime,
error: `Image provider error: ${err.message}`,
error: sanitizeErrorMessage(err) || "Image provider error",
});
}
}
@@ -2353,7 +2358,7 @@ async function fetchImageEndpoint(url, headers, body, provider, log) {
return {
success: false,
status: 502,
error: `Image provider error: ${err.message}`,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -2448,7 +2453,11 @@ async function handleHyperbolicImageGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -2702,7 +2711,11 @@ async function handleNanoBananaImageGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -2894,7 +2907,11 @@ async function handleSDWebUIImageGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -2996,7 +3013,11 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -3104,7 +3125,11 @@ async function handleHaiperImageGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -3232,7 +3257,11 @@ async function handleLeonardoImageGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}
@@ -3318,7 +3347,11 @@ async function handleIdeogramImageGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
}
}

View File

@@ -24,6 +24,7 @@ import {
} from "../utils/comfyuiClient.ts";
import { saveCallLog } from "@/lib/usageDb";
import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
function normalizeKieSunoModel(model: string): string {
const map: Record<string, string> = {
@@ -219,7 +220,11 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Music provider error",
};
}
}
@@ -369,7 +374,7 @@ async function handleKieMusicGeneration({
return {
success: false,
status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502,
error: `Music provider error: ${err instanceof Error ? err.message : String(err)}`,
error: sanitizeErrorMessage(err) || "Music provider error",
};
}
}
@@ -487,7 +492,11 @@ async function handleSunoMusicGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Music provider error",
};
}
}
@@ -598,6 +607,10 @@ async function handleUdioMusicGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Music provider error",
};
}
}

View File

@@ -30,6 +30,7 @@ import {
extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts";
import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../utils/error.ts";
/**
* Handle video generation request
@@ -200,7 +201,11 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Video provider error",
};
}
}
@@ -288,7 +293,11 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Video provider error",
};
}
}
@@ -419,7 +428,7 @@ async function handleKieVideoGeneration({
return {
success: false,
status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502,
error: `Video provider error: ${err instanceof Error ? err.message : String(err)}`,
error: sanitizeErrorMessage(err) || "Video provider error",
};
}
}
@@ -619,7 +628,11 @@ async function handleRunwayVideoGeneration({
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` };
return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Video provider error",
};
}
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { buildClaudeAuthFile, ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) {
if (error instanceof ClaudeAuthFileError) {
@@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) {
);
}
const message = error instanceof Error ? error.message : "Failed to export Claude auth file";
const message = sanitizeErrorMessage(error) || "Failed to export Claude auth file";
return NextResponse.json({ error: message }, { status: 500 });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) {
if (error instanceof CodexAuthFileError) {
@@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) {
);
}
const message = error instanceof Error ? error.message : "Failed to export Codex auth file";
const message = sanitizeErrorMessage(error) || "Failed to export Codex auth file";
return NextResponse.json({ error: message }, { status: 500 });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { buildGeminiAuthFile, GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) {
if (error instanceof GeminiAuthFileError) {
@@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) {
);
}
const message = error instanceof Error ? error.message : "Failed to export Gemini auth file";
const message = sanitizeErrorMessage(error) || "Failed to export Gemini auth file";
return NextResponse.json({ error: message }, { status: 500 });
}

View File

@@ -1,23 +1,30 @@
"use client";
import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
/**
* OAuth Callback Page Content
* OAuth Callback Page
*
* Reads URL params via window.location.search (not useSearchParams) to avoid
* the Next.js Suspense boundary requirement, which can delay hydration in popup
* windows that navigate back from a cross-origin OAuth page (e.g. Google).
* Sends the callback data back via three methods in order of reliability:
* 1. postMessage to window.opener (may be null after COOP cross-origin nav)
* 2. BroadcastChannel (same-origin, works across browsing context groups)
* 3. localStorage storage event (works across browsing context groups)
*/
function CallbackContent() {
const searchParams = useSearchParams();
const [status, setStatus] = useState("processing");
export default function CallbackPage() {
const [status, setStatus] = useState<"processing" | "success" | "done" | "manual">("processing");
const [currentUrl, setCurrentUrl] = useState("");
const t = useTranslations("auth");
useEffect(() => {
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
const errorDescription = searchParams.get("error_description");
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const error = params.get("error");
const errorDescription = params.get("error_description");
const callbackData = {
code,
@@ -29,23 +36,23 @@ function CallbackContent() {
let sent = false;
// Check if this callback is from expected origin/port
const expectedOrigins = [
window.location.origin, // Same origin (for most providers)
"http://localhost:1455", // Codex specific port
];
// Method 1: postMessage to opener (popup mode)
// Method 1: postMessage to opener (popup mode).
// May be null when Google OAuth's COOP header severs the opener reference.
if (window.opener) {
try {
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev
// Target this origin specifically — popup mode is only used when isTrueLocalhost,
// so the opener is always on the same origin as the callback page.
window.opener.postMessage(
{ type: "oauth_callback", data: callbackData },
window.location.origin
);
sent = true;
} catch (e) {
console.log("postMessage failed:", e);
}
}
// Method 2: BroadcastChannel (same origin tabs)
// Method 2: BroadcastChannel — works across browsing context groups for same origin.
try {
const channel = new BroadcastChannel("oauth_callback");
channel.postMessage(callbackData);
@@ -55,7 +62,8 @@ function CallbackContent() {
console.log("BroadcastChannel failed:", e);
}
// Method 3: localStorage event (fallback)
// Method 3: localStorage — triggers storage event in all same-origin windows,
// regardless of browsing context group isolation from COOP.
try {
localStorage.setItem(
"oauth_callback",
@@ -67,25 +75,25 @@ function CallbackContent() {
}
if (sent && (code || error)) {
// Use setTimeout to avoid synchronous setState in effect
setTimeout(() => {
// Only auto-close if opened as popup (has opener) — remote access keeps tab open
if (window.opener) {
setStatus("success");
setTimeout(() => {
window.close();
// If can't close (not a popup), show success message
setTimeout(() => setStatus("done"), 500);
}, 1500);
} else {
// Opened as new tab (remote access) — show URL for manual copy
setStatus("done");
}
}, 0);
if (window.opener) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initialization effect, window-only
setStatus("success");
setTimeout(() => {
window.close();
// If close is prevented (browser policy), fall through to manual close prompt.
setTimeout(() => setStatus("done"), 500);
}, 1500);
} else {
// Opened as new tab or opener severed by COOP — show close prompt.
setStatus("done");
}
} else {
setTimeout(() => setStatus("manual"), 0);
// No code/error in URL or all send methods failed — show URL for manual copy.
// Batch the URL and status update so they render together (React 18 auto-batching).
setCurrentUrl(window.location.href);
setStatus("manual");
}
}, [searchParams]);
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
@@ -124,9 +132,7 @@ function CallbackContent() {
<h1 className="text-xl font-semibold mb-2">{t("copyUrl")}</h1>
<p className="text-text-muted mb-4">{t("copyUrlManual")}</p>
<div className="bg-surface border border-border rounded-lg p-3 text-left">
<code className="text-xs break-all">
{typeof window !== "undefined" ? window.location.href : ""}
</code>
<code className="text-xs break-all">{currentUrl}</code>
</div>
</>
)}
@@ -134,29 +140,3 @@ function CallbackContent() {
</div>
);
}
/**
* OAuth Callback Page
* Receives callback from OAuth providers and sends data back via multiple methods
*/
export default function CallbackPage() {
const t = useTranslations("auth");
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="text-center p-8">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<p className="text-text-muted">{t("loading")}</p>
</div>
</div>
}
>
<CallbackContent />
</Suspense>
);
}

View File

@@ -704,13 +704,13 @@ export const autoSearchIndex: AutoGenSearchItem[] = [
fileName: "reference/PROVIDER_REFERENCE.md",
section: "Reference",
content:
"Auto-generated from src/shared/constants/providers.ts — do not edit by hand. Regenerate with: npm run gen:provider-reference Last generated: 2026-05-13 Total providers: 177. See category breakdown below. - Free — free tier with API key (configured via dashboard) - OAuth — sign-in flow handled by Omn",
"Auto-generated from src/shared/constants/providers.ts — do not edit by hand. Regenerate with: npm run gen:provider-reference Last generated: 2026-05-17 Total providers: 177. See category breakdown below. - Free — free tier with API key (configured via dashboard) - OAuth — sign-in flow handled by Omn",
headings: [
"Categories",
"Free Tier (OAuth-first or no-key) (5)",
"OAuth Providers (11)",
"Web Cookie Providers (5)",
"API Key Providers (paid / paid-with-free-credits) (123)",
"Web Cookie Providers (6)",
"API Key Providers (paid / paid-with-free-credits) (122)",
"Local Providers (10)",
"Search Providers (11)",
"Audio-only Providers (7)",

View File

@@ -9,7 +9,9 @@ export function generateClaudeConfig(options: {
model?: string;
}): string {
let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const model = options.model || "claude-3-5-sonnet-20241022";

View File

@@ -9,7 +9,9 @@ export function generateClineConfig(options: {
model?: string;
}): string {
let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {

View File

@@ -18,7 +18,9 @@ export async function generateCodexConfig(options: {
}): Promise<string> {
const y = await loadYaml();
let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {

View File

@@ -18,7 +18,9 @@ export async function generateContinueConfig(options: {
}): Promise<string> {
const y = await loadYaml();
let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {

View File

@@ -9,7 +9,9 @@ export function generateKilocodeConfig(options: {
model?: string;
}): string {
let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {

View File

@@ -9,7 +9,9 @@ export function generateOpencodeConfig(options: {
model?: string;
}): string {
let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1);
let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = {

View File

@@ -90,6 +90,7 @@ export const gemini = {
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token ?? null,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,