diff --git a/open-sse/executors/qwen.js b/open-sse/executors/qwen.js index 2c9d48ae..b62414dc 100644 --- a/open-sse/executors/qwen.js +++ b/open-sse/executors/qwen.js @@ -1,60 +1,68 @@ -import { platform, arch } from "os"; import { DefaultExecutor } from "./default.js"; +import { PROVIDERS } from "../config/providers.js"; +import { OAUTH_ENDPOINTS } from "../config/appConstants.js"; -/** portal.qwen.ai */ -const qwenCodeVersion = "0.13.2"; -const qwenStainless = { - runtimeVersion: "v22.17.0", +/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */ +const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)"; +const QWEN_STAINLESS = { + os: "Linux", + arch: "x64", lang: "js", + runtime: "node", + runtimeVersion: "v18.19.1", packageVersion: "5.11.0", - retryCount: "0", - runtime: "node" + retryCount: "1" }; -const qwenDefaultSystemMessage = { +const QWEN_DEFAULT_SYSTEM_MESSAGE = { role: "system", content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }] }; -function qwenStainlessOsLabel() { - const p = platform(); - if (p === "darwin") return "MacOS"; - if (p === "win32") return "Windows"; - if (p === "linux") return "Linux"; - return p; -} - -function qwenUserAgent() { - return `QwenCode/${qwenCodeVersion} (${platform()}; ${arch()})`; -} - function ensureQwenSystemMessage(body) { if (!body || typeof body !== "object") return body; const next = { ...body }; if (Array.isArray(next.messages)) { - next.messages = [qwenDefaultSystemMessage, ...next.messages]; + next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages]; } else { - next.messages = [qwenDefaultSystemMessage]; + next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE]; } return next; } +function isQwenThinkingActive(body) { + const thinking = body?.thinking; + if (thinking === true || body?.enable_thinking === true) return true; + return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled"; +} + +// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto". +function sanitizeQwenThinkingToolChoice(body) { + if (!isQwenThinkingActive(body)) return body; + const tc = body.tool_choice; + const incompatible = tc === "required" || (typeof tc === "object" && tc !== null); + if (!incompatible) return body; + return { ...body, tool_choice: "auto" }; +} + function buildQwenUpstreamHeaders(credentials, stream = true) { const token = credentials?.apiKey || credentials?.accessToken || ""; - const ua = qwenUserAgent(); const headers = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, - "User-Agent": ua, - "X-DashScope-UserAgent": ua, - "X-Stainless-Runtime-Version": qwenStainless.runtimeVersion, - "X-Stainless-Lang": qwenStainless.lang, - "X-Stainless-Arch": arch(), - "X-Stainless-Package-Version": qwenStainless.packageVersion, - "X-DashScope-CacheControl": "enable", - "X-Stainless-Retry-Count": qwenStainless.retryCount, - "X-Stainless-Os": qwenStainlessOsLabel(), + "User-Agent": QWEN_USER_AGENT, "X-DashScope-AuthType": "qwen-oauth", - "X-Stainless-Runtime": qwenStainless.runtime + "X-DashScope-CacheControl": "enable", + "X-DashScope-UserAgent": QWEN_USER_AGENT, + "X-Stainless-Arch": QWEN_STAINLESS.arch, + "X-Stainless-Lang": QWEN_STAINLESS.lang, + "X-Stainless-Os": QWEN_STAINLESS.os, + "X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion, + "X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount, + "X-Stainless-Runtime": QWEN_STAINLESS.runtime, + "X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion, + Connection: "keep-alive", + "Accept-Language": "*", + "Sec-Fetch-Mode": "cors" }; headers.Accept = stream ? "text/event-stream" : "application/json"; return headers; @@ -65,17 +73,57 @@ export class QwenExecutor extends DefaultExecutor { super("qwen"); } + // Qwen tokens are bound to a resource_url returned at OAuth time. + // Using portal.qwen.ai when the token is issued for another shard returns 401/403. + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const resourceUrl = credentials?.providerSpecificData?.resourceUrl; + const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai"; + return `https://${host}/v1/chat/completions`; + } + buildHeaders(credentials, stream = true) { return buildQwenUpstreamHeaders(credentials, stream); } transformRequest(model, body, stream, credentials) { - const next = body && typeof body === "object" ? { ...body } : body; + let next = body && typeof body === "object" ? { ...body } : body; if (stream && next?.messages && !next.stream_options) { next.stream_options = { include_usage: true }; } + next = sanitizeQwenThinkingToolChoice(next); return ensureQwenSystemMessage(next); } + + // Override to capture resource_url from refresh response (required for buildUrl). + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) return null; + try { + const response = await fetch(OAUTH_ENDPOINTS.qwen.token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + client_id: PROVIDERS.qwen.clientId + }) + }); + if (!response.ok) return null; + const tokens = await response.json(); + log?.info?.("TOKEN", "qwen refreshed"); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || credentials.refreshToken, + expiresIn: tokens.expires_in, + providerSpecificData: { + ...(credentials.providerSpecificData || {}), + ...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {}) + } + }; + } catch (error) { + log?.error?.("TOKEN", `qwen refresh error: ${error.message}`); + return null; + } + } } export default QwenExecutor; diff --git a/package.json b/package.json index 46233581..c7e96ed1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "lowdb": "^7.0.1", + "marked": "^18.0.1", "monaco-editor": "^0.55.1", "next": "^16.1.6", "node-forge": "^1.3.3", diff --git a/src/app/globals.css b/src/app/globals.css index d83e49e9..445c0b87 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -217,3 +217,55 @@ body { .traffic-light.green { background: #27C93F; } + +/* Changelog markdown body */ +.changelog-body h1 { + font-size: 1.4rem; + font-weight: 700; + margin: 1.5rem 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} +.changelog-body h1:first-child { + margin-top: 0; +} +.changelog-body h2 { + font-size: 1.05rem; + font-weight: 600; + margin: 1rem 0 0.5rem; + color: var(--color-primary); +} +.changelog-body h3 { + font-size: 0.95rem; + font-weight: 600; + margin: 0.75rem 0 0.4rem; +} +.changelog-body ul { + list-style: disc; + padding-left: 1.5rem; + margin: 0.5rem 0; +} +.changelog-body li { + margin: 0.25rem 0; + line-height: 1.6; +} +.changelog-body p { + margin: 0.5rem 0; + line-height: 1.6; +} +.changelog-body code { + background: var(--color-bg-alt); + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.875em; + font-family: ui-monospace, monospace; +} +.changelog-body a { + color: var(--color-primary); + text-decoration: underline; +} +.changelog-body hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 1.5rem 0; +} diff --git a/src/shared/components/ChangelogModal.js b/src/shared/components/ChangelogModal.js new file mode 100644 index 00000000..591f6a9e --- /dev/null +++ b/src/shared/components/ChangelogModal.js @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { createPortal } from "react-dom"; +import PropTypes from "prop-types"; +import { marked } from "marked"; +import { GITHUB_CONFIG } from "@/shared/constants/config"; + +marked.setOptions({ gfm: true, breaks: true }); + +export default function ChangelogModal({ isOpen, onClose }) { + const [html, setHtml] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const modalRef = useRef(null); + + useEffect(() => { + if (!isOpen || html) return; + setLoading(true); + setError(""); + fetch(GITHUB_CONFIG.changelogUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) + .then((md) => setHtml(marked.parse(md))) + .catch((err) => setError(err.message || "Failed to load")) + .finally(() => setLoading(false)); + }, [isOpen, html]); + + useEffect(() => { + const handleClickOutside = (e) => { + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose(); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen, onClose]); + + if (!isOpen || typeof document === "undefined") return null; + + return createPortal( +
+ {/* Overlay */} +
+ + {/* Modal content */} +
+ {/* Header */} +
+

Change Log

+ +
+ + {/* Body */} +
+ {loading && ( +
+ progress_activity + Loading... +
+ )} + {error && ( +
Failed to load changelog: {error}
+ )} + {!loading && !error && html && ( +
+ )} +
+
+
, + document.body + ); +} + +ChangelogModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 29973a41..f87902bc 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -5,8 +5,7 @@ import { useMemo } from "react"; import Link from "next/link"; import PropTypes from "prop-types"; import ProviderIcon from "@/shared/components/ProviderIcon"; -import { ThemeToggle, LanguageSwitcher } from "@/shared/components"; -import NineRemoteButton from "@/shared/components/NineRemoteButton"; +import HeaderMenu from "@/shared/components/HeaderMenu"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; import { translate } from "@/i18n/runtime"; @@ -249,25 +248,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) { ) : null}
- {/* Right actions */} -
- {/* 9Remote button */} - - - {/* Language switcher */} - - - {/* Theme toggle */} - - - {/* Logout button */} - + {/* Right actions - consolidated into dropdown menu */} +
+
); diff --git a/src/shared/components/HeaderMenu.js b/src/shared/components/HeaderMenu.js new file mode 100644 index 00000000..4a9ece46 --- /dev/null +++ b/src/shared/components/HeaderMenu.js @@ -0,0 +1,87 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import PropTypes from "prop-types"; +import ThemeToggle from "./ThemeToggle"; +import LanguageSwitcher from "./LanguageSwitcher"; +import NineRemoteButton from "./NineRemoteButton"; +import ChangelogModal from "./ChangelogModal"; + +export default function HeaderMenu({ onLogout }) { + const [isOpen, setIsOpen] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + const openChangelog = () => { + setIsOpen(false); + setChangelogOpen(true); + }; + + return ( + <> +
+ + + {isOpen && ( +
+ + +
+ +
+ +
+ + Theme +
+ + + +
+ +
+
+ )} +
+ + setChangelogOpen(false)} /> + + ); +} + +HeaderMenu.propTypes = { + onLogout: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/LanguageSwitcher.js b/src/shared/components/LanguageSwitcher.js index abe5cf4c..5284688b 100644 --- a/src/shared/components/LanguageSwitcher.js +++ b/src/shared/components/LanguageSwitcher.js @@ -109,8 +109,9 @@ export default function LanguageSwitcher({ className = "" }) { title="Language" data-i18n-skip="true" > - {getLocaleInfo(locale).flag} - {locale.split("-")[0]} + language + {getLocaleInfo(locale).name} + {getLocaleInfo(locale).flag} {/* Portal modal - renders at document.body to avoid parent layout constraints */} diff --git a/src/shared/components/index.js b/src/shared/components/index.js index c057b8bd..5c616b81 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -19,6 +19,8 @@ export { default as ManualConfigModal } from "./ManualConfigModal"; export { default as UsageStats } from "./UsageStats"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as NineRemoteButton } from "./NineRemoteButton"; +export { default as HeaderMenu } from "./HeaderMenu"; +export { default as ChangelogModal } from "./ChangelogModal"; export { default as RequestLogger } from "./RequestLogger"; export { default as KiroAuthModal } from "./KiroAuthModal"; export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper"; diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index 53a3e53e..dd6d4b75 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -2,11 +2,16 @@ import pkg from "../../../package.json" with { type: "json" }; // App configuration export const APP_CONFIG = { - name: "Endpoint Proxy", + name: "9Router proxy", description: "AI Infrastructure Management", version: pkg.version, }; +// GitHub configuration +export const GITHUB_CONFIG = { + changelogUrl: "https://raw.githubusercontent.com/decolua/9router/refs/heads/master/CHANGELOG.md", +}; + // Theme configuration export const THEME_CONFIG = { storageKey: "theme", diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index f5ce84bf..c12df1fd 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -3,7 +3,7 @@ // Free Providers (kiro first, iflow last) export const FREE_PROVIDERS = { kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" }, - qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work." }, "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." }, // gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" }, // codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" }, diff --git a/tests/unit/antigravity-cache.test.js b/tests/unit/antigravity-cache.test.js new file mode 100644 index 00000000..e81cf407 --- /dev/null +++ b/tests/unit/antigravity-cache.test.js @@ -0,0 +1,216 @@ +/** + * Integration test: Antigravity (AG) prompt caching behavior. + * + * Verifies: + * 1. Same sessionId + repeated long prompt → cache hit (cachedContentTokenCount > 0) + * 2. Different sessionId (same account) → cache miss + * 3. Cross-account cache share? (call A warmup → B same prompt/session, check hit) + * + * Reads real OAuth refreshToken from ~/.9router/db.json. + * Enable with: AG_CACHE_TEST=1 npm test + */ + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER } from "../../open-sse/config/appConstants.js"; + +const ENABLE = process.env.AG_CACHE_TEST === "1"; +const DB_PATH = path.join(os.homedir(), ".9router", "db.json"); +const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const MIN_CACHE_TOKENS = 100; // AG implicit cache threshold observed ~1024-2048 +const LONG_TEXT = ("You are a careful assistant. Always follow these rules. ".repeat(300)).trim(); + +function loadAgConnections() { + if (!fs.existsSync(DB_PATH)) return []; + const db = JSON.parse(fs.readFileSync(DB_PATH, "utf8")); + return (db.providerConnections || []).filter( + c => c.provider === "antigravity" && c.isActive && c.refreshToken && c.projectId + ); +} + +async function refreshAccessToken(refreshToken) { + const { clientId, clientSecret } = PROVIDERS.antigravity; + const res = await fetch(OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret + }) + }); + if (!res.ok) throw new Error(`refresh failed ${res.status}`); + const json = await res.json(); + return json.access_token; +} + +async function callAg({ accessToken, projectId, sessionId, longText, userText }) { + const baseUrl = PROVIDERS.antigravity.baseUrls[0]; + const body = { + project: projectId, + model: "gemini-3-flash", + userAgent: "antigravity", + requestType: "agent", + requestId: `agent-${crypto.randomUUID()}`, + request: { + systemInstruction: { role: "system", parts: [{ text: longText }] }, + contents: [{ role: "user", parts: [{ text: userText }] }], + sessionId + } + }; + const res = await fetch(`${baseUrl}/v1internal:generateContent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + "User-Agent": ANTIGRAVITY_HEADERS["User-Agent"], + [INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value, + "X-Machine-Session-Id": sessionId + }, + body: JSON.stringify(body) + }); + const json = await res.json(); + const usage = json?.response?.usageMetadata || json?.usageMetadata || {}; + return { + status: res.status, + promptTokens: usage.promptTokenCount || 0, + cachedTokens: usage.cachedContentTokenCount || 0, + totalTokens: usage.totalTokenCount || 0, + raw: json + }; +} + +describe.skipIf(!ENABLE)("Antigravity cache behavior (real API)", () => { + const conns = loadAgConnections(); + + it("has at least one active AG connection with refreshToken", () => { + expect(conns.length).toBeGreaterThan(0); + }); + + it("same sessionId → cache hit on repeated call", async () => { + const [acc] = conns; + const token = await refreshAccessToken(acc.refreshToken); + const sessionId = `test-same-${crypto.randomUUID()}`; + + const r1 = await callAg({ accessToken: token, projectId: acc.projectId, sessionId, longText: LONG_TEXT, userText: "Reply with OK only." }); + const r2 = await callAg({ accessToken: token, projectId: acc.projectId, sessionId, longText: LONG_TEXT, userText: "Reply with OK only." }); + + console.log(`[same-session ${acc.email}] r1: prompt=${r1.promptTokens} cached=${r1.cachedTokens} | r2: prompt=${r2.promptTokens} cached=${r2.cachedTokens}`); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r2.cachedTokens).toBeGreaterThanOrEqual(MIN_CACHE_TOKENS); + }, 60000); + + it("different sessionId (same account) → cache still hits (session-independent)", async () => { + const [acc] = conns; + const token = await refreshAccessToken(acc.refreshToken); + + const r1 = await callAg({ accessToken: token, projectId: acc.projectId, sessionId: `test-diff-a-${crypto.randomUUID()}`, longText: LONG_TEXT, userText: "Reply with OK only." }); + const r2 = await callAg({ accessToken: token, projectId: acc.projectId, sessionId: `test-diff-b-${crypto.randomUUID()}`, longText: LONG_TEXT, userText: "Reply with OK only." }); + + console.log(`[diff-session ${acc.email}] r1: cached=${r1.cachedTokens} | r2: cached=${r2.cachedTokens}`); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + // AG cache is content-based, not session-based → both calls hit + expect(r2.cachedTokens).toBeGreaterThanOrEqual(MIN_CACHE_TOKENS); + }, 60000); + + it.skipIf(conns.length < 2)("cross-account → cache SHARED (content-based global cache)", async () => { + const [accA, accB] = conns; + const [tokenA, tokenB] = await Promise.all([ + refreshAccessToken(accA.refreshToken), + refreshAccessToken(accB.refreshToken) + ]); + + // Account A warmup with its own sessionId + const a1 = await callAg({ accessToken: tokenA, projectId: accA.projectId, sessionId: `cross-a-${crypto.randomUUID()}`, longText: LONG_TEXT, userText: "Reply with OK only." }); + // Account B with DIFFERENT sessionId → if cache shares across accounts, it still hits + const b1 = await callAg({ accessToken: tokenB, projectId: accB.projectId, sessionId: `cross-b-${crypto.randomUUID()}`, longText: LONG_TEXT, userText: "Reply with OK only." }); + + console.log(`[cross-account] A cached=${a1.cachedTokens} | B cached=${b1.cachedTokens} (${accA.email} → ${accB.email})`); + + expect(a1.status).toBe(200); + expect(b1.status).toBe(200); + // Cache is shared globally across accounts (content-based) + expect(b1.cachedTokens).toBeGreaterThanOrEqual(MIN_CACHE_TOKENS); + }, 90000); + + // ─── Codex-style sessionId comparison ──────────────────────────────── + // Codex derives sessionId from hash(conversation history), keeping it + // stable per-conversation. Test whether this strategy improves cache + // hit rate vs random sessionId on AG with a fresh unique prompt. + it("codex-style sessionId vs random sessionId on unique prompt", async () => { + const [acc] = conns; + const token = await refreshAccessToken(acc.refreshToken); + + // Build a unique conversation so no pre-existing cache can interfere + const uniqueMarker = crypto.randomUUID(); + const uniqueLong = `MARKER-${uniqueMarker}. ${LONG_TEXT}`; + const userText = "Reply with OK only."; + + // Codex-style: sess_${sha256(systemInstruction + userContent).slice(0,32)} + const hash = crypto.createHash("sha256").update(uniqueLong + "\n" + userText).digest("hex").slice(0, 32); + const codexStyleSessionId = `sess_${hash}`; + + const N = 4; + const randomResults = []; + const codexResults = []; + + // Strategy A: random sessionId each call + for (let i = 0; i < N; i++) { + const r = await callAg({ + accessToken: token, projectId: acc.projectId, + sessionId: `rand-${crypto.randomUUID()}`, + longText: uniqueLong, userText + }); + randomResults.push(r); + console.log(`[random call ${i + 1}] cached=${r.cachedTokens}`); + } + + // Strategy B: codex-style stable sessionId (same hash for every call) + for (let i = 0; i < N; i++) { + const r = await callAg({ + accessToken: token, projectId: acc.projectId, + sessionId: codexStyleSessionId, + longText: uniqueLong, userText + }); + codexResults.push(r); + console.log(`[codex call ${i + 1}] cached=${r.cachedTokens}`); + } + + const randomHitRate = randomResults.filter(r => r.cachedTokens >= MIN_CACHE_TOKENS).length / N; + const codexHitRate = codexResults.filter(r => r.cachedTokens >= MIN_CACHE_TOKENS).length / N; + console.log(`[summary] randomHitRate=${randomHitRate} codexHitRate=${codexHitRate}`); + + randomResults.forEach(r => expect(r.status).toBe(200)); + codexResults.forEach(r => expect(r.status).toBe(200)); + // No strict comparison — just report. AG cache is session-independent per prior tests. + }, 180000); + + it("unique prompt (never seen) → explore when cache starts hitting", async () => { + const [acc] = conns; + const token = await refreshAccessToken(acc.refreshToken); + // Unique marker to guarantee no one has cached this exact prompt before + const uniqueLong = `UNIQUE-${crypto.randomUUID()}. ${LONG_TEXT}`; + const sessionId = `unique-${crypto.randomUUID()}`; + + const results = []; + for (let i = 0; i < 5; i++) { + const r = await callAg({ accessToken: token, projectId: acc.projectId, sessionId, longText: uniqueLong, userText: "Reply with OK only." }); + results.push(r); + console.log(`[unique-prompt call ${i + 1}] prompt=${r.promptTokens} cached=${r.cachedTokens}`); + } + + results.forEach(r => expect(r.status).toBe(200)); + // Log whether any call ever hits cache — no strict assertion (exploratory) + const anyHit = results.some(r => r.cachedTokens >= MIN_CACHE_TOKENS); + console.log(`[unique-prompt] any-hit=${anyHit}`); + }, 90000); +});