changes
This commit is contained in:
1 parent
faabf7ba7a
commit
0454aecf70
36 files changed
+715
-223
No files matched your search
@@ -345,7 +345,6 @@ Documentation/usage lookup:
|
|||||||
|
|
||||||
- Use `context7` for latest dependency usage.
|
- Use `context7` for latest dependency usage.
|
||||||
|
|
||||||
|
|
||||||
## 🚨 Local Quality Checks (Optional Flow)
|
## 🚨 Local Quality Checks (Optional Flow)
|
||||||
|
|
||||||
After a set of changes, run these three checks in parallel instead of full lint immediately:
|
After a set of changes, run these three checks in parallel instead of full lint immediately:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ Quando você usa suas IDEs favoritas, já encontrou estes problemas?
|
|||||||
Este projeto contém melhorias significativas de desenvolvimento, correção de erros e melhorias de configuração em relação ao projeto original base.
|
Este projeto contém melhorias significativas de desenvolvimento, correção de erros e melhorias de configuração em relação ao projeto original base.
|
||||||
|
|
||||||
Principais melhorias incluem:
|
Principais melhorias incluem:
|
||||||
|
|
||||||
- **Interface de Gerenciamento de API Aprimorada:** Seletores dinâmicos de modelos e recursos de "Copiar Nome da API" implementados para facilitar a integração com outras ferramentas.
|
- **Interface de Gerenciamento de API Aprimorada:** Seletores dinâmicos de modelos e recursos de "Copiar Nome da API" implementados para facilitar a integração com outras ferramentas.
|
||||||
- **Streaming Robusto (SSE):** Correção de bugs críticos no streaming via Server-Sent Events (SSE) no endpoint proxy `/v1/chat/completions` para suportar perfeitamente IDEs como Cursor, OpenCode e Windsurf.
|
- **Streaming Robusto (SSE):** Correção de bugs críticos no streaming via Server-Sent Events (SSE) no endpoint proxy `/v1/chat/completions` para suportar perfeitamente IDEs como Cursor, OpenCode e Windsurf.
|
||||||
- **Rebranding e Localização:** Rebranding completo para Gemini Nexus com traduções melhoradas em Português (Brasil) e padrões em Inglês.
|
- **Rebranding e Localização:** Rebranding completo para Gemini Nexus com traduções melhoradas em Português (Brasil) e padrões em Inglês.
|
||||||
|
|||||||
Generated
+26
-11
@@ -112,6 +112,7 @@
|
|||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
"code-inspector-plugin": "^1.3.3",
|
||||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^37.3.1",
|
"electron": "^37.3.1",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-packager-languages": "^0.6.0",
|
"electron-packager-languages": "^0.6.0",
|
||||||
@@ -1997,6 +1998,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -14272,6 +14280,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -20668,7 +20694,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20689,7 +20714,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20710,7 +20734,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20731,7 +20754,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20752,7 +20774,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20773,7 +20794,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20794,7 +20814,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20815,7 +20834,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20836,7 +20854,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20857,7 +20874,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20878,7 +20894,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,8 @@
|
|||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||||
|
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
"code-inspector-plugin": "^1.3.3",
|
||||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^37.3.1",
|
"electron": "^37.3.1",
|
||||||
"electron-devtools-installer": "^4.0.0",
|
"electron-devtools-installer": "^4.0.0",
|
||||||
"electron-packager-languages": "^0.6.0",
|
"electron-packager-languages": "^0.6.0",
|
||||||
|
|||||||
+26
-3
@@ -7,8 +7,28 @@ const projectRoot = process.cwd();
|
|||||||
const allowedExtensions = /\.(ts|tsx|js|jsx|json|md|html|jsonc|yml|yaml|css)$/;
|
const allowedExtensions = /\.(ts|tsx|js|jsx|json|md|html|jsonc|yml|yaml|css)$/;
|
||||||
|
|
||||||
// Arquivos/Pastas a ignorar (já curados ou irrelevantes)
|
// Arquivos/Pastas a ignorar (já curados ou irrelevantes)
|
||||||
const ignoreDirs = ['node_modules', '.git', '.gemini', 'dist', 'out', 'build', 'images', 'docs', '.planning'];
|
const ignoreDirs = [
|
||||||
const ignoreFiles = ['README.md', 'README.pt-BR.md', 'CHANGELOG.md', 'forge.config.ts', 'package.json', 'rename.js', 'rename-deep.js', 'task.md', 'walkthrough.md'];
|
'node_modules',
|
||||||
|
'.git',
|
||||||
|
'.gemini',
|
||||||
|
'dist',
|
||||||
|
'out',
|
||||||
|
'build',
|
||||||
|
'images',
|
||||||
|
'docs',
|
||||||
|
'.planning',
|
||||||
|
];
|
||||||
|
const ignoreFiles = [
|
||||||
|
'README.md',
|
||||||
|
'README.pt-BR.md',
|
||||||
|
'CHANGELOG.md',
|
||||||
|
'forge.config.ts',
|
||||||
|
'package.json',
|
||||||
|
'rename.js',
|
||||||
|
'rename-deep.js',
|
||||||
|
'task.md',
|
||||||
|
'walkthrough.md',
|
||||||
|
];
|
||||||
|
|
||||||
function walkAndReplace(dir) {
|
function walkAndReplace(dir) {
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir);
|
||||||
@@ -63,7 +83,10 @@ function walkAndReplace(dir) {
|
|||||||
content = content.replace(/antigravity/g, 'geminiNexus');
|
content = content.replace(/antigravity/g, 'geminiNexus');
|
||||||
|
|
||||||
// Restore GitHub links
|
// Restore GitHub links
|
||||||
content = content.replace(new RegExp(tempUrlMarker, 'g'), 'github.com/Draculabo/AntigravityManager');
|
content = content.replace(
|
||||||
|
new RegExp(tempUrlMarker, 'g'),
|
||||||
|
'github.com/Draculabo/AntigravityManager',
|
||||||
|
);
|
||||||
|
|
||||||
// Cleanup any double replacements
|
// Cleanup any double replacements
|
||||||
content = content.replace(/GeminiNexus Nexus/g, 'Gemini Nexus');
|
content = content.replace(/GeminiNexus Nexus/g, 'Gemini Nexus');
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ function AccountProxyItem({ account }: { account: CloudAccount }) {
|
|||||||
const [proxySaved, setProxySaved] = useState(false);
|
const [proxySaved, setProxySaved] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 py-3 border-b last:border-0">
|
<div className="flex items-center justify-between gap-4 border-b py-3 last:border-0">
|
||||||
<div className="flex items-center gap-3 overflow-hidden">
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
{account.avatar_url ? (
|
{account.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={account.avatar_url}
|
src={account.avatar_url}
|
||||||
alt={account.name || ''}
|
alt={account.name || ''}
|
||||||
className="bg-muted h-8 w-8 rounded-full border shrink-0"
|
className="bg-muted h-8 w-8 shrink-0 rounded-full border"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded-full border shrink-0">
|
<div className="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full border">
|
||||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -31,7 +31,7 @@ function AccountProxyItem({ account }: { account: CloudAccount }) {
|
|||||||
<span className="truncate text-sm font-semibold">
|
<span className="truncate text-sm font-semibold">
|
||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">{account.email}</span>
|
<span className="text-muted-foreground truncate text-xs">{account.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,7 +67,9 @@ function AccountProxyItem({ account }: { account: CloudAccount }) {
|
|||||||
/>
|
/>
|
||||||
<div className="w-16">
|
<div className="w-16">
|
||||||
{proxySaved && (
|
{proxySaved && (
|
||||||
<span className="text-[10px] text-green-500 whitespace-nowrap">{t('cloud.card.proxySaved')}</span>
|
<span className="text-[10px] whitespace-nowrap text-green-500">
|
||||||
|
{t('cloud.card.proxySaved')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +89,7 @@ export function AccountProxySettings() {
|
|||||||
<CardDescription>{t('settings.proxy.accountProxies.description')}</CardDescription>
|
<CardDescription>{t('settings.proxy.accountProxies.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex justify-center p-6">
|
<CardContent className="flex justify-center p-6">
|
||||||
<Loader2 className="animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground animate-spin" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1078,14 +1078,9 @@ export class CloudAccountRepo {
|
|||||||
'enterpriseGcpProjectId',
|
'enterpriseGcpProjectId',
|
||||||
projectPayload,
|
projectPayload,
|
||||||
);
|
);
|
||||||
this.upsertItemValue(
|
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.enterprisePreferences', projectEntry);
|
||||||
tx,
|
|
||||||
'geminiNexusUnifiedStateSync.enterprisePreferences',
|
|
||||||
projectEntry,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
tx
|
tx.delete(itemTable)
|
||||||
.delete(itemTable)
|
|
||||||
.where(eq(itemTable.key, 'geminiNexusUnifiedStateSync.enterprisePreferences'))
|
.where(eq(itemTable.key, 'geminiNexusUnifiedStateSync.enterprisePreferences'))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,11 +182,7 @@ function getTelemetryField(storage: Record<string, unknown>, key: string): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureTelemetryObject(storage: Record<string, unknown>): Record<string, unknown> {
|
function ensureTelemetryObject(storage: Record<string, unknown>): Record<string, unknown> {
|
||||||
if (
|
if (!storage.telemetry || !isObjectLike(storage.telemetry) || Array.isArray(storage.telemetry)) {
|
||||||
!storage.telemetry ||
|
|
||||||
!isObjectLike(storage.telemetry) ||
|
|
||||||
Array.isArray(storage.telemetry)
|
|
||||||
) {
|
|
||||||
storage.telemetry = {};
|
storage.telemetry = {};
|
||||||
}
|
}
|
||||||
return storage.telemetry as Record<string, unknown>;
|
return storage.telemetry as Record<string, unknown>;
|
||||||
|
|||||||
@@ -121,11 +121,7 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
const cmd = proc.cmd?.toLowerCase() || '';
|
const cmd = proc.cmd?.toLowerCase() || '';
|
||||||
|
|
||||||
// Skip manager process
|
// Skip manager process
|
||||||
if (
|
if (name.includes('manager') || cmd.includes('manager') || cmd.includes('gemini-nexus')) {
|
||||||
name.includes('manager') ||
|
|
||||||
cmd.includes('manager') ||
|
|
||||||
cmd.includes('gemini-nexus')
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ export const MainLayout: React.FC = () => {
|
|||||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
||||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
||||||
<img src="/src/assets/icon.png" alt="Gemini Nexus Logo" className="h-full w-full object-contain" />
|
<img
|
||||||
|
src="/src/assets/icon.png"
|
||||||
|
alt="Gemini Nexus Logo"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -82,21 +82,9 @@ export function transformClaudeRequestIn(
|
|||||||
let isThinkingEnabled =
|
let isThinkingEnabled =
|
||||||
thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled;
|
thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled;
|
||||||
|
|
||||||
if (isThinkingEnabled) {
|
// Thinking is always kept enabled for thinking-capable models.
|
||||||
const globalSig = SignatureStore.get();
|
// All functionCall/functionResponse/thinking parts get unconditional
|
||||||
const hasFunctionCalls = claudeReq.messages.some((m) => {
|
// thoughtSignature injection in buildContents(), so no guard is needed here.
|
||||||
if (Array.isArray(m.content)) {
|
|
||||||
return m.content.some((b) => b.type === 'tool_use');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
|
|
||||||
if (!isGeminiFlashModel(config.finalModel)) {
|
|
||||||
isThinkingEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const generationConfig = buildGenerationConfig(
|
const generationConfig = buildGenerationConfig(
|
||||||
claudeReq,
|
claudeReq,
|
||||||
@@ -137,7 +125,7 @@ export function transformClaudeRequestIn(
|
|||||||
systemInstruction?: { parts: { text: string }[] };
|
systemInstruction?: { parts: { text: string }[] };
|
||||||
generationConfig?: GenerationConfig;
|
generationConfig?: GenerationConfig;
|
||||||
tools?: GeminiToolDeclaration[];
|
tools?: GeminiToolDeclaration[];
|
||||||
toolConfig?: { functionCallingConfig: { mode: string } };
|
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||||
} = {
|
} = {
|
||||||
contents,
|
contents,
|
||||||
safetySettings,
|
safetySettings,
|
||||||
@@ -155,7 +143,27 @@ export function transformClaudeRequestIn(
|
|||||||
|
|
||||||
if (tools) {
|
if (tools) {
|
||||||
innerRequest.tools = tools;
|
innerRequest.tools = tools;
|
||||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'VALIDATED' } };
|
const toolChoice = claudeReq.tool_choice;
|
||||||
|
if (toolChoice === 'none') {
|
||||||
|
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||||
|
} else if (toolChoice === 'auto' || toolChoice === undefined) {
|
||||||
|
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||||
|
} else if (
|
||||||
|
typeof toolChoice === 'object' &&
|
||||||
|
toolChoice.type === 'function' &&
|
||||||
|
toolChoice.function?.name
|
||||||
|
) {
|
||||||
|
innerRequest.toolConfig = {
|
||||||
|
functionCallingConfig: {
|
||||||
|
mode: 'ANY',
|
||||||
|
allowedFunctionNames: [toolChoice.function.name],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||||
|
}
|
||||||
|
} else if (claudeReq.tool_choice === 'none') {
|
||||||
|
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject googleSearch tool if needed (and not already done by buildTools)
|
// Inject googleSearch tool if needed (and not already done by buildTools)
|
||||||
@@ -396,36 +404,8 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }, mappe
|
|||||||
function buildSystemInstruction(
|
function buildSystemInstruction(
|
||||||
system: ClaudeRequest['system'],
|
system: ClaudeRequest['system'],
|
||||||
): { parts: { text: string }[] } | null {
|
): { parts: { text: string }[] } | null {
|
||||||
const assistantIdentityDirective =
|
|
||||||
'You are Gemini Nexus, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n' +
|
|
||||||
'You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n' +
|
|
||||||
'**Absolute paths only**\n' +
|
|
||||||
'**Proactiveness**';
|
|
||||||
const identityMarker = 'You are Gemini Nexus';
|
|
||||||
|
|
||||||
const parts: { text: string }[] = [];
|
const parts: { text: string }[] = [];
|
||||||
|
|
||||||
let hasIdentityDirective = false;
|
|
||||||
|
|
||||||
if (system) {
|
|
||||||
if (isString(system)) {
|
|
||||||
if (system.includes(identityMarker)) {
|
|
||||||
hasIdentityDirective = true;
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(system)) {
|
|
||||||
for (const block of system) {
|
|
||||||
if (block.type === 'text' && block.text.includes(identityMarker)) {
|
|
||||||
hasIdentityDirective = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasIdentityDirective) {
|
|
||||||
parts.push({ text: assistantIdentityDirective });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (system) {
|
if (system) {
|
||||||
if (isString(system)) {
|
if (isString(system)) {
|
||||||
parts.push({ text: system });
|
parts.push({ text: system });
|
||||||
@@ -436,7 +416,6 @@ function buildSystemInstruction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we pushed at least something
|
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
return { parts };
|
return { parts };
|
||||||
}
|
}
|
||||||
@@ -501,7 +480,10 @@ function buildContents(
|
|||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
const role = msg.role === 'assistant' ? 'model' : msg.role;
|
let role = msg.role === 'assistant' ? 'model' : msg.role;
|
||||||
|
if (role === 'function') {
|
||||||
|
role = 'user';
|
||||||
|
}
|
||||||
const parts: {
|
const parts: {
|
||||||
text?: string;
|
text?: string;
|
||||||
inlineData?: {
|
inlineData?: {
|
||||||
@@ -518,14 +500,25 @@ function buildContents(
|
|||||||
|
|
||||||
for (const block of contentBlocks) {
|
for (const block of contentBlocks) {
|
||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim()))
|
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim())) {
|
||||||
parts.push({ text: block.text.trim() });
|
let text = block.text.trim();
|
||||||
|
if (msg.role === 'function' && msg.name) {
|
||||||
|
text = `Function result (${msg.name}):\n${text}`;
|
||||||
|
} else if (msg.name) {
|
||||||
|
text = `[${msg.name}]: ${text}`;
|
||||||
|
}
|
||||||
|
parts.push({ text });
|
||||||
|
}
|
||||||
} else if (block.type === 'thinking') {
|
} else if (block.type === 'thinking') {
|
||||||
const part: any = { text: block.thinking, thought: true };
|
const part: any = { text: block.thinking, thought: true };
|
||||||
cleanJsonSchema(part);
|
cleanJsonSchema(part);
|
||||||
if (block.signature) {
|
if (block.signature) {
|
||||||
lastThoughtSignature = block.signature;
|
lastThoughtSignature = block.signature;
|
||||||
part.thoughtSignature = block.signature;
|
part.thoughtSignature = block.signature;
|
||||||
|
} else {
|
||||||
|
// Inject fallback signature for thinking blocks without one
|
||||||
|
const fallbackSig = SignatureStore.get() || 'skip_thought_signature_validator';
|
||||||
|
part.thoughtSignature = fallbackSig;
|
||||||
}
|
}
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
} else if (block.type === 'image') {
|
} else if (block.type === 'image') {
|
||||||
@@ -537,12 +530,10 @@ function buildContents(
|
|||||||
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
||||||
cleanJsonSchema(part);
|
cleanJsonSchema(part);
|
||||||
toolIdToName.set(block.id, block.name);
|
toolIdToName.set(block.id, block.name);
|
||||||
|
// Always inject thoughtSignature on functionCall parts.
|
||||||
|
// The v1internal API rejects functionCalls without signatures when thinking is enabled.
|
||||||
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
||||||
if (finalSig) {
|
part.thoughtSignature = finalSig || 'skip_thought_signature_validator';
|
||||||
part.thoughtSignature = finalSig;
|
|
||||||
} else if (isThinkingEnabled && isGeminiFlashModel(mappedModel)) {
|
|
||||||
part.thoughtSignature = 'skip_thought_signature_validator';
|
|
||||||
}
|
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
} else if (block.type === 'tool_result') {
|
} else if (block.type === 'tool_result') {
|
||||||
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||||
@@ -560,11 +551,13 @@ function buildContents(
|
|||||||
const part: any = {
|
const part: any = {
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
name: funcName,
|
name: funcName,
|
||||||
response: { result: mergedContent },
|
response: block.is_error ? { error: mergedContent } : { result: mergedContent },
|
||||||
id: block.tool_use_id,
|
id: block.tool_use_id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (lastThoughtSignature) part.thoughtSignature = lastThoughtSignature;
|
// Always inject thoughtSignature on functionResponse parts for v1internal compatibility
|
||||||
|
part.thoughtSignature =
|
||||||
|
lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator';
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
} else if (block.type === 'redacted_thinking') {
|
} else if (block.type === 'redacted_thinking') {
|
||||||
parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true });
|
parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true });
|
||||||
@@ -669,11 +662,13 @@ function buildGenerationConfig(
|
|||||||
return thinkingConfig;
|
return thinkingConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maxTokens = claudeReq.max_completion_tokens ?? claudeReq.max_tokens;
|
||||||
|
|
||||||
if (isOpenAIPath) {
|
if (isOpenAIPath) {
|
||||||
config.temperature = claudeReq.temperature ?? 1.0;
|
config.temperature = claudeReq.temperature ?? 1.0;
|
||||||
config.topP = claudeReq.top_p ?? 0.95;
|
config.topP = claudeReq.top_p ?? 0.95;
|
||||||
if (claudeReq.max_tokens !== undefined) {
|
if (maxTokens !== undefined) {
|
||||||
config.maxOutputTokens = claudeReq.max_tokens;
|
config.maxOutputTokens = maxTokens;
|
||||||
} else {
|
} else {
|
||||||
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
||||||
}
|
}
|
||||||
@@ -683,25 +678,39 @@ function buildGenerationConfig(
|
|||||||
if (isThinkingEnabled) {
|
if (isThinkingEnabled) {
|
||||||
config.thinkingConfig = buildThinkingConfig();
|
config.thinkingConfig = buildThinkingConfig();
|
||||||
}
|
}
|
||||||
return config;
|
} else {
|
||||||
|
if (isThinkingEnabled) {
|
||||||
|
config.thinkingConfig = buildThinkingConfig();
|
||||||
|
}
|
||||||
|
if (claudeReq.temperature !== undefined) {
|
||||||
|
config.temperature = claudeReq.temperature;
|
||||||
|
}
|
||||||
|
if (claudeReq.top_p !== undefined) {
|
||||||
|
config.topP = claudeReq.top_p;
|
||||||
|
}
|
||||||
|
if (claudeReq.top_k !== undefined) {
|
||||||
|
config.topK = claudeReq.top_k;
|
||||||
|
}
|
||||||
|
if (maxTokens !== undefined) {
|
||||||
|
config.maxOutputTokens = maxTokens;
|
||||||
|
}
|
||||||
|
const defaultStopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
||||||
|
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||||
|
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
|
||||||
|
} else {
|
||||||
|
config.stopSequences = defaultStopSequences;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isThinkingEnabled) {
|
if (claudeReq.response_format) {
|
||||||
config.thinkingConfig = buildThinkingConfig();
|
if (claudeReq.response_format.type === 'json_object') {
|
||||||
|
config.responseMimeType = 'application/json';
|
||||||
|
} else if (claudeReq.response_format.type === 'json_schema' && claudeReq.response_format.json_schema) {
|
||||||
|
config.responseMimeType = 'application/json';
|
||||||
|
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (claudeReq.temperature !== undefined) {
|
|
||||||
config.temperature = claudeReq.temperature;
|
|
||||||
}
|
|
||||||
if (claudeReq.top_p !== undefined) {
|
|
||||||
config.topP = claudeReq.top_p;
|
|
||||||
}
|
|
||||||
if (claudeReq.top_k !== undefined) {
|
|
||||||
config.topK = claudeReq.top_k;
|
|
||||||
}
|
|
||||||
if (claudeReq.max_tokens !== undefined) {
|
|
||||||
config.maxOutputTokens = claudeReq.max_tokens;
|
|
||||||
}
|
|
||||||
config.stopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class NonStreamingProcessor {
|
|||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
public process(geminiResponse: GeminiResponse): ClaudeResponse {
|
public process(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||||
const candidate = geminiResponse.candidates?.[0];
|
const candidate = geminiResponse.candidates?.[0];
|
||||||
const parts = candidate?.content?.parts || [];
|
const parts = candidate?.content?.parts || [];
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class NonStreamingProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Build response
|
// 5. Build response
|
||||||
return this.buildResponse(geminiResponse);
|
return this.buildResponse(geminiResponse, modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private processPart(part: GeminiPart) {
|
private processPart(part: GeminiPart) {
|
||||||
@@ -213,7 +213,7 @@ class NonStreamingProcessor {
|
|||||||
this.thinkingSignature = null;
|
this.thinkingSignature = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildResponse(geminiResponse: GeminiResponse): ClaudeResponse {
|
private buildResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||||
const finishReason = geminiResponse.candidates?.[0]?.finishReason;
|
const finishReason = geminiResponse.candidates?.[0]?.finishReason;
|
||||||
|
|
||||||
let stopReason = 'end_turn';
|
let stopReason = 'end_turn';
|
||||||
@@ -221,6 +221,15 @@ class NonStreamingProcessor {
|
|||||||
stopReason = 'tool_use';
|
stopReason = 'tool_use';
|
||||||
} else if (finishReason === 'MAX_TOKENS') {
|
} else if (finishReason === 'MAX_TOKENS') {
|
||||||
stopReason = 'max_tokens';
|
stopReason = 'max_tokens';
|
||||||
|
} else if (
|
||||||
|
finishReason === 'SAFETY' ||
|
||||||
|
finishReason === 'RECITATION' ||
|
||||||
|
finishReason === 'OTHER' ||
|
||||||
|
finishReason === 'IMAGE_SAFETY' ||
|
||||||
|
finishReason === 'PROHIBITED_CONTENT' ||
|
||||||
|
finishReason === 'SPII'
|
||||||
|
) {
|
||||||
|
stopReason = 'stop_sequence';
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage: Usage = {
|
const usage: Usage = {
|
||||||
@@ -234,7 +243,7 @@ class NonStreamingProcessor {
|
|||||||
id: geminiResponse.responseId || `msg_${uuidv4()}`,
|
id: geminiResponse.responseId || `msg_${uuidv4()}`,
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
model: geminiResponse.modelVersion || '',
|
model: geminiResponse.modelVersion || modelName || '',
|
||||||
content: this.contentBlocks,
|
content: this.contentBlocks,
|
||||||
stop_reason: stopReason,
|
stop_reason: stopReason,
|
||||||
usage: usage,
|
usage: usage,
|
||||||
@@ -245,7 +254,7 @@ class NonStreamingProcessor {
|
|||||||
/**
|
/**
|
||||||
* Public API: Transform Gemini Response to Claude Response
|
* Public API: Transform Gemini Response to Claude Response
|
||||||
*/
|
*/
|
||||||
export function transformResponse(geminiResponse: GeminiResponse): ClaudeResponse {
|
export function transformResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||||
const processor = new NonStreamingProcessor();
|
const processor = new NonStreamingProcessor();
|
||||||
return processor.process(geminiResponse);
|
return processor.process(geminiResponse, modelName);
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export class StreamingState {
|
|||||||
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public emitMessageStart(rawJson: any): string {
|
public emitMessageStart(rawJson: any, modelName?: string): string {
|
||||||
if (this.messageStartSent) return '';
|
if (this.messageStartSent) return '';
|
||||||
|
|
||||||
const usageMeta = rawJson.usageMetadata;
|
const usageMeta = rawJson.usageMetadata;
|
||||||
@@ -69,7 +69,7 @@ export class StreamingState {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [],
|
content: [],
|
||||||
model: rawJson.modelVersion || '',
|
model: rawJson.modelVersion || modelName || '',
|
||||||
stop_reason: null,
|
stop_reason: null,
|
||||||
stop_sequence: null,
|
stop_sequence: null,
|
||||||
usage: usage,
|
usage: usage,
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ export function getAllDynamicModels(
|
|||||||
modelIds.add(modelId);
|
modelIds.add(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const aliasModelId of Object.keys(CLAUDE_TO_GEMINI)) {
|
||||||
|
modelIds.add(aliasModelId);
|
||||||
|
}
|
||||||
|
|
||||||
if (dynamicModelIds) {
|
if (dynamicModelIds) {
|
||||||
for (const dynamicModelId of dynamicModelIds) {
|
for (const dynamicModelId of dynamicModelIds) {
|
||||||
if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) {
|
if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) {
|
||||||
|
|||||||
@@ -164,9 +164,7 @@ export class QuotaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasNextEndpoint && (status === 429 || (isNumber(status) && status >= 500))) {
|
if (hasNextEndpoint && (status === 429 || (isNumber(status) && status >= 500))) {
|
||||||
logger.warn(
|
logger.warn(`Quota API ${endpoint} returned ${status}, falling back to next endpoint`);
|
||||||
`Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
|
|
||||||
);
|
|
||||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -53,12 +53,15 @@ export interface ClaudeRequest {
|
|||||||
tools?: Tool[];
|
tools?: Tool[];
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
max_completion_tokens?: number;
|
||||||
stop_sequences?: string[];
|
stop_sequences?: string[];
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
top_k?: number;
|
top_k?: number;
|
||||||
thinking?: ThinkingConfig;
|
thinking?: ThinkingConfig;
|
||||||
metadata?: Metadata;
|
metadata?: Metadata;
|
||||||
|
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||||
|
response_format?: { type: 'text' | 'json_object' | 'json_schema'; json_schema?: any };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThinkingConfig {
|
export interface ThinkingConfig {
|
||||||
@@ -77,6 +80,7 @@ export interface SystemBlock {
|
|||||||
export interface Message {
|
export interface Message {
|
||||||
role: string;
|
role: string;
|
||||||
content: string | ContentBlock[];
|
content: string | ContentBlock[];
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentBlock =
|
export type ContentBlock =
|
||||||
@@ -185,6 +189,8 @@ export interface GenerationConfig {
|
|||||||
responseModalities?: string[];
|
responseModalities?: string[];
|
||||||
/** Image generation configuration */
|
/** Image generation configuration */
|
||||||
imageConfig?: ImageConfig;
|
imageConfig?: ImageConfig;
|
||||||
|
/** Response schema for structured output */
|
||||||
|
responseSchema?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ i18n
|
|||||||
timeout: 'Request Timeout (Seconds)',
|
timeout: 'Request Timeout (Seconds)',
|
||||||
accountProxies: {
|
accountProxies: {
|
||||||
title: 'Account Proxies',
|
title: 'Account Proxies',
|
||||||
description: 'Configure specific proxies for individual accounts. These will override the global upstream proxy for that specific account.',
|
description:
|
||||||
|
'Configure specific proxies for individual accounts. These will override the global upstream proxy for that specific account.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
modelMapping: {
|
modelMapping: {
|
||||||
@@ -1109,7 +1110,8 @@ i18n
|
|||||||
timeout: 'Тайм-аут запроса (сек)',
|
timeout: 'Тайм-аут запроса (сек)',
|
||||||
accountProxies: {
|
accountProxies: {
|
||||||
title: 'Прокси аккаунтов',
|
title: 'Прокси аккаунтов',
|
||||||
description: 'Настройка индивидуальных прокси для каждого аккаунта. Эти настройки переопределят глобальный прокси для данного аккаунта.',
|
description:
|
||||||
|
'Настройка индивидуальных прокси для каждого аккаунта. Эти настройки переопределят глобальный прокси для данного аккаунта.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
modelMapping: {
|
modelMapping: {
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ const vi = {
|
|||||||
timeout: 'Thời gian chờ request (giây)',
|
timeout: 'Thời gian chờ request (giây)',
|
||||||
accountProxies: {
|
accountProxies: {
|
||||||
title: 'Proxy tài khoản',
|
title: 'Proxy tài khoản',
|
||||||
description: 'Cấu hình proxy riêng cho từng tài khoản. Những cấu hình này sẽ ghi đè proxy trung gian chung cho tài khoản đó.',
|
description:
|
||||||
|
'Cấu hình proxy riêng cho từng tài khoản. Những cấu hình này sẽ ghi đè proxy trung gian chung cho tài khoản đó.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
modelMapping: {
|
modelMapping: {
|
||||||
|
|||||||
+1
-3
@@ -468,9 +468,7 @@ app
|
|||||||
logger.info('Startup: Auto-Switch enabled, starting monitor...');
|
logger.info('Startup: Auto-Switch enabled, starting monitor...');
|
||||||
CloudMonitorService.start();
|
CloudMonitorService.start();
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info('Startup: Auto-Switch disabled, running one-time quota and AI credits sync...');
|
||||||
'Startup: Auto-Switch disabled, running one-time quota and AI credits sync...',
|
|
||||||
);
|
|
||||||
await CloudMonitorService.poll();
|
await CloudMonitorService.poll();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+14
-12
@@ -24,15 +24,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Loader2, Copy, CheckCircle, Code, Terminal, Eye, EyeOff } from 'lucide-react';
|
||||||
Loader2,
|
|
||||||
Copy,
|
|
||||||
CheckCircle,
|
|
||||||
Code,
|
|
||||||
Terminal,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -187,7 +179,12 @@ function ProxyPage() {
|
|||||||
groups['Google'].push(id);
|
groups['Google'].push(id);
|
||||||
} else if (lower.includes('claude')) {
|
} else if (lower.includes('claude')) {
|
||||||
groups['Anthropic'].push(id);
|
groups['Anthropic'].push(id);
|
||||||
} else if (lower.includes('gpt') || lower.includes('o1') || lower.includes('o3') || lower.includes('dall-e')) {
|
} else if (
|
||||||
|
lower.includes('gpt') ||
|
||||||
|
lower.includes('o1') ||
|
||||||
|
lower.includes('o3') ||
|
||||||
|
lower.includes('dall-e')
|
||||||
|
) {
|
||||||
groups['OpenAI'].push(id);
|
groups['OpenAI'].push(id);
|
||||||
} else {
|
} else {
|
||||||
groups['Other'].push(id);
|
groups['Other'].push(id);
|
||||||
@@ -634,7 +631,10 @@ print(response.choices[0].message.content)`;
|
|||||||
<div className="flex flex-col gap-4 border-b border-gray-200 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700">
|
<div className="flex flex-col gap-4 border-b border-gray-200 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
Model <span className="text-muted-foreground ml-1 font-normal">({availableModelIds.length} available)</span>
|
Model{' '}
|
||||||
|
<span className="text-muted-foreground ml-1 font-normal">
|
||||||
|
({availableModelIds.length} available)
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={activeModelTab} onValueChange={setActiveModelTab}>
|
<Select value={activeModelTab} onValueChange={setActiveModelTab}>
|
||||||
<SelectTrigger className="h-9 w-[280px] font-mono text-xs">
|
<SelectTrigger className="h-9 w-[280px] font-mono text-xs">
|
||||||
@@ -645,7 +645,9 @@ print(response.choices[0].message.content)`;
|
|||||||
if (models.length === 0) return null;
|
if (models.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<SelectGroup key={provider}>
|
<SelectGroup key={provider}>
|
||||||
<SelectLabel className="text-xs font-bold text-muted-foreground">{provider}</SelectLabel>
|
<SelectLabel className="text-muted-foreground text-xs font-bold">
|
||||||
|
{provider}
|
||||||
|
</SelectLabel>
|
||||||
{models.map((modelId) => (
|
{models.map((modelId) => (
|
||||||
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
|
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
|
||||||
{modelId}
|
{modelId}
|
||||||
|
|||||||
@@ -244,11 +244,7 @@ export class GeminiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
private writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
||||||
if (
|
if (!res.raw || !isFunction(res.raw.writeHead) || !isFunction(res.raw.write)) {
|
||||||
!res.raw ||
|
|
||||||
!isFunction(res.raw.writeHead) ||
|
|
||||||
!isFunction(res.raw.write)
|
|
||||||
) {
|
|
||||||
res.header('Content-Type', 'text/event-stream');
|
res.header('Content-Type', 'text/event-stream');
|
||||||
res.header('Cache-Control', 'no-cache');
|
res.header('Cache-Control', 'no-cache');
|
||||||
res.header('Connection', 'keep-alive');
|
res.header('Connection', 'keep-alive');
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ export interface OpenAIChatRequest {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
max_completion_tokens?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
|
stream_options?: { include_usage?: boolean };
|
||||||
size?: string;
|
size?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
tools?: OpenAITool[];
|
tools?: OpenAITool[];
|
||||||
tool_choice?: string | { type: string; function?: { name: string } };
|
tool_choice?: string | { type: string; function?: { name: string } };
|
||||||
response_format?: { type?: string };
|
response_format?: { type?: string };
|
||||||
|
seed?: number;
|
||||||
|
presence_penalty?: number;
|
||||||
|
frequency_penalty?: number;
|
||||||
|
n?: number;
|
||||||
|
logprobs?: boolean;
|
||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es';
|
import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ProxyService } from './proxy.service';
|
import { ProxyService } from './proxy.service';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { trafficLogger } from '../../../utils/traffic-logger';
|
||||||
import {
|
import {
|
||||||
OpenAIChatRequest,
|
OpenAIChatRequest,
|
||||||
AnthropicChatRequest,
|
AnthropicChatRequest,
|
||||||
@@ -66,11 +68,17 @@ export class ProxyController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to list models';
|
const message = error instanceof Error ? error.message : 'Failed to list models';
|
||||||
this.logger.error(message, error instanceof Error ? error.stack : undefined);
|
this.logger.error(message, error instanceof Error ? error.stack : undefined);
|
||||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
|
const status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
error: {
|
const { type, code } = this.resolveOpenAIErrorTypeAndCode(status, message);
|
||||||
message,
|
const errorPayload: Record<string, unknown> = {
|
||||||
type: 'server_error',
|
message,
|
||||||
},
|
type,
|
||||||
|
};
|
||||||
|
if (code) {
|
||||||
|
errorPayload.code = code;
|
||||||
|
}
|
||||||
|
res.status(status).send({
|
||||||
|
error: errorPayload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,16 +304,23 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
|
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
|
||||||
|
const requestId = uuidv4();
|
||||||
|
const startTime = Date.now();
|
||||||
|
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.proxyService.handleChatCompletions(body);
|
const result = await this.proxyService.handleChatCompletions(body, requestId);
|
||||||
|
|
||||||
if (body.stream && this.isObservableLike(result)) {
|
if (body.stream && this.isObservableLike(result)) {
|
||||||
this.writeSseResponse(res, result);
|
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
trafficLogger.logOutbound(requestId, '/v1/chat/completions', HttpStatus.OK, result, duration);
|
||||||
res.status(HttpStatus.OK).send(result);
|
res.status(HttpStatus.OK).send(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
trafficLogger.logError(requestId, '/v1/chat/completions', error);
|
||||||
this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error);
|
this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,12 +734,14 @@ export class ProxyController {
|
|||||||
return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe);
|
return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
private writeSseResponse(
|
||||||
if (
|
res: FastifyReply,
|
||||||
!res.raw ||
|
stream: Observable<unknown>,
|
||||||
!isFunction(res.raw.writeHead) ||
|
requestId?: string,
|
||||||
!isFunction(res.raw.write)
|
endpoint?: string,
|
||||||
) {
|
startTime?: number,
|
||||||
|
): void {
|
||||||
|
if (!res.raw || !isFunction(res.raw.writeHead) || !isFunction(res.raw.write)) {
|
||||||
res.header('Content-Type', 'text/event-stream');
|
res.header('Content-Type', 'text/event-stream');
|
||||||
res.header('Cache-Control', 'no-cache');
|
res.header('Cache-Control', 'no-cache');
|
||||||
res.header('Connection', 'keep-alive');
|
res.header('Connection', 'keep-alive');
|
||||||
@@ -742,6 +759,10 @@ export class ProxyController {
|
|||||||
Connection: 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isFunction(res.raw.flushHeaders)) {
|
||||||
|
res.raw.flushHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
const subscription = stream.subscribe({
|
const subscription = stream.subscribe({
|
||||||
next: (chunk) => {
|
next: (chunk) => {
|
||||||
if (res.raw.writableEnded) {
|
if (res.raw.writableEnded) {
|
||||||
@@ -749,19 +770,32 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
const payload = isString(chunk) ? chunk : String(chunk ?? '');
|
const payload = isString(chunk) ? chunk : String(chunk ?? '');
|
||||||
res.raw.write(payload);
|
res.raw.write(payload);
|
||||||
|
if (requestId && endpoint) {
|
||||||
|
trafficLogger.logSseChunk(requestId, endpoint, chunk);
|
||||||
|
}
|
||||||
|
if (isFunction((res.raw as any).flush)) {
|
||||||
|
(res.raw as any).flush();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (res.raw.writableEnded) {
|
if (res.raw.writableEnded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const status = this.resolveErrorHttpStatus(message);
|
||||||
|
const { type, code } = this.resolveOpenAIErrorTypeAndCode(status, message);
|
||||||
|
const errorPayload: Record<string, unknown> = {
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
if (code) {
|
||||||
|
errorPayload.code = code;
|
||||||
|
}
|
||||||
res.raw.write(
|
res.raw.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
error: {
|
error: errorPayload,
|
||||||
message,
|
})}
|
||||||
type: 'server_error',
|
\n\n`,
|
||||||
},
|
|
||||||
})}\n\n`,
|
|
||||||
);
|
);
|
||||||
res.raw.end();
|
res.raw.end();
|
||||||
},
|
},
|
||||||
@@ -981,11 +1015,16 @@ export class ProxyController {
|
|||||||
const message = overrideMessage ?? this.resolveErrorMessageText(error);
|
const message = overrideMessage ?? this.resolveErrorMessageText(error);
|
||||||
const status = this.resolveErrorHttpStatus(message);
|
const status = this.resolveErrorHttpStatus(message);
|
||||||
this.logProxyEndpointError(endpoint, status, message, error);
|
this.logProxyEndpointError(endpoint, status, message, error);
|
||||||
|
const { type, code } = this.resolveOpenAIErrorTypeAndCode(status, message);
|
||||||
|
const errorPayload: Record<string, unknown> = {
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
if (code) {
|
||||||
|
errorPayload.code = code;
|
||||||
|
}
|
||||||
res.status(status).send({
|
res.status(status).send({
|
||||||
error: {
|
error: errorPayload,
|
||||||
message,
|
|
||||||
type: 'server_error',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,6 +1084,32 @@ export class ProxyController {
|
|||||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveOpenAIErrorTypeAndCode(
|
||||||
|
status: HttpStatus,
|
||||||
|
message: string,
|
||||||
|
): { type: string; code?: string } {
|
||||||
|
const lowered = message.toLowerCase();
|
||||||
|
if (status === HttpStatus.UNAUTHORIZED) {
|
||||||
|
return { type: 'invalid_request_error', code: 'invalid_api_key' };
|
||||||
|
}
|
||||||
|
if (status === HttpStatus.FORBIDDEN) {
|
||||||
|
return { type: 'invalid_request_error', code: 'permission_denied' };
|
||||||
|
}
|
||||||
|
if (status === HttpStatus.TOO_MANY_REQUESTS) {
|
||||||
|
return { type: 'rate_limit_error', code: 'rate_limit_exceeded' };
|
||||||
|
}
|
||||||
|
if (status === HttpStatus.BAD_GATEWAY || status === HttpStatus.GATEWAY_TIMEOUT) {
|
||||||
|
return { type: 'server_error', code: 'bad_gateway' };
|
||||||
|
}
|
||||||
|
if (status === HttpStatus.SERVICE_UNAVAILABLE) {
|
||||||
|
return { type: 'server_error', code: 'temporarily_unavailable' };
|
||||||
|
}
|
||||||
|
if (lowered.includes('invalid') || lowered.includes('malformed') || lowered.includes('bad request')) {
|
||||||
|
return { type: 'invalid_request_error', code: 'invalid_request_error' };
|
||||||
|
}
|
||||||
|
return { type: 'server_error', code: 'internal_error' };
|
||||||
|
}
|
||||||
|
|
||||||
private logProxyEndpointError(
|
private logProxyEndpointError(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
status: HttpStatus,
|
status: HttpStatus,
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ export class ProxyService {
|
|||||||
|
|
||||||
lastError = error;
|
lastError = error;
|
||||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, error);
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, error);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const penaltyDecision = this.classifyUpstreamFailure(errorMessage);
|
||||||
|
if (!penaltyDecision.retry) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError || new Error('Request failed after retries');
|
throw lastError || new Error('Request failed after retries');
|
||||||
@@ -363,6 +369,12 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||||
|
|
||||||
|
const errorMessage = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
|
const penaltyDecision = this.classifyUpstreamFailure(errorMessage);
|
||||||
|
if (!penaltyDecision.retry) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +464,12 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||||
|
|
||||||
|
const errorMessage = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
|
const penaltyDecision = this.classifyUpstreamFailure(errorMessage);
|
||||||
|
if (!penaltyDecision.retry) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +746,7 @@ export class ProxyService {
|
|||||||
token.token.upstream_proxy_url,
|
token.token.upstream_proxy_url,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
return this.processStreamResponse(stream, request.model);
|
return this.processStreamResponse(stream, request.model, request.stream_options);
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
||||||
@@ -787,7 +805,7 @@ export class ProxyService {
|
|||||||
token.token.upstream_proxy_url,
|
token.token.upstream_proxy_url,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
return this.processStreamResponse(stream, request.model);
|
return this.processStreamResponse(stream, request.model, request.stream_options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.generateInternalWithStreamFallback(
|
const response = await this.generateInternalWithStreamFallback(
|
||||||
@@ -806,6 +824,12 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||||
|
|
||||||
|
const errorMessage = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
|
const penaltyDecision = this.classifyUpstreamFailure(errorMessage);
|
||||||
|
if (!penaltyDecision.retry) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError || new Error('Request failed after retries');
|
throw lastError || new Error('Request failed after retries');
|
||||||
@@ -928,18 +952,38 @@ export class ProxyService {
|
|||||||
private processStreamResponse(
|
private processStreamResponse(
|
||||||
upstreamStream: NodeJS.ReadableStream,
|
upstreamStream: NodeJS.ReadableStream,
|
||||||
model: string,
|
model: string,
|
||||||
|
streamOptions?: { include_usage?: boolean },
|
||||||
): Observable<string> {
|
): Observable<string> {
|
||||||
return new Observable<string>((subscriber) => {
|
return new Observable<string>((subscriber) => {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let hasEmittedChunk = false;
|
let hasEmittedChunk = false;
|
||||||
let hasSentDone = false;
|
let hasSentDone = false;
|
||||||
|
let isThinking = false;
|
||||||
|
let toolCallIndex = 0;
|
||||||
|
let lastUsageMetadata: GeminiResponse['usageMetadata'];
|
||||||
|
|
||||||
const streamId = `chatcmpl-${uuidv4()}`;
|
const streamId = `chatcmpl-${uuidv4()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Emit initial role chunk immediately to prevent strict clients (like Hermes) from timing out during long TTFT
|
||||||
|
const initialChunk = {
|
||||||
|
id: streamId,
|
||||||
|
object: 'chat.completion.chunk',
|
||||||
|
created: created,
|
||||||
|
model: model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { role: 'assistant', content: '' },
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
subscriber.next(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
||||||
|
hasEmittedChunk = true;
|
||||||
|
|
||||||
const pushChunk = (payload: Record<string, unknown>): void => {
|
const pushChunk = (payload: Record<string, unknown>): void => {
|
||||||
hasEmittedChunk = true;
|
|
||||||
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
|
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -960,8 +1004,17 @@ export class ProxyService {
|
|||||||
const candidate = json.candidates?.[0];
|
const candidate = json.candidates?.[0];
|
||||||
const parts = candidate?.content?.parts || [];
|
const parts = candidate?.content?.parts || [];
|
||||||
|
|
||||||
|
if (json.usageMetadata) {
|
||||||
|
lastUsageMetadata = json.usageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.thought && part.text) {
|
if (part.thought && part.text) {
|
||||||
|
let textToEmit = part.text;
|
||||||
|
if (!isThinking) {
|
||||||
|
isThinking = true;
|
||||||
|
textToEmit = `<think>\n${textToEmit}`;
|
||||||
|
}
|
||||||
const reasoningChunk = {
|
const reasoningChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -970,7 +1023,7 @@ export class ProxyService {
|
|||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: { reasoning_content: part.text },
|
delta: { content: textToEmit },
|
||||||
finish_reason: null,
|
finish_reason: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -980,6 +1033,7 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (part.functionCall) {
|
if (part.functionCall) {
|
||||||
|
const currentToolIndex = toolCallIndex++;
|
||||||
const toolCallChunk = {
|
const toolCallChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -991,7 +1045,7 @@ export class ProxyService {
|
|||||||
delta: {
|
delta: {
|
||||||
tool_calls: [
|
tool_calls: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: currentToolIndex,
|
||||||
id: part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`,
|
id: part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`,
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
@@ -1031,6 +1085,11 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (part.text) {
|
if (part.text) {
|
||||||
|
let textToEmit = part.text;
|
||||||
|
if (isThinking && !part.thought) {
|
||||||
|
isThinking = false;
|
||||||
|
textToEmit = `\n</think>\n\n${textToEmit}`;
|
||||||
|
}
|
||||||
const contentChunk = {
|
const contentChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1039,7 +1098,7 @@ export class ProxyService {
|
|||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: { content: part.text },
|
delta: { content: textToEmit },
|
||||||
finish_reason: null,
|
finish_reason: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1049,6 +1108,24 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidate?.finishReason) {
|
if (candidate?.finishReason) {
|
||||||
|
if (isThinking) {
|
||||||
|
isThinking = false;
|
||||||
|
const closingChunk = {
|
||||||
|
id: streamId,
|
||||||
|
object: 'chat.completion.chunk',
|
||||||
|
created: created,
|
||||||
|
model: model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { content: '\n</think>\n\n' },
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
pushChunk(closingChunk);
|
||||||
|
}
|
||||||
|
|
||||||
const finishChunk = {
|
const finishChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1091,6 +1168,23 @@ export class ProxyService {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (streamOptions?.include_usage && lastUsageMetadata) {
|
||||||
|
const usageChunk = {
|
||||||
|
id: streamId,
|
||||||
|
object: 'chat.completion.chunk',
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: lastUsageMetadata.promptTokenCount || 0,
|
||||||
|
completion_tokens: lastUsageMetadata.candidatesTokenCount || 0,
|
||||||
|
total_tokens:
|
||||||
|
(lastUsageMetadata.promptTokenCount || 0) +
|
||||||
|
(lastUsageMetadata.candidatesTokenCount || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
pushChunk(usageChunk);
|
||||||
|
}
|
||||||
if (!hasSentDone) {
|
if (!hasSentDone) {
|
||||||
subscriber.next('data: [DONE]\n\n');
|
subscriber.next('data: [DONE]\n\n');
|
||||||
hasSentDone = true;
|
hasSentDone = true;
|
||||||
@@ -1098,12 +1192,17 @@ export class ProxyService {
|
|||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamStream.on('error', (err: unknown) => {
|
upstreamStream.on('error', (err) => {
|
||||||
// Convert to clean Error to avoid circular reference issues (socket objects)
|
this.logger.error('OpenAI-compatible stream error:', err);
|
||||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
||||||
this.logger.error(`OpenAI-compatible stream error: ${cleanError.message}`);
|
|
||||||
subscriber.error(cleanError);
|
subscriber.error(cleanError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeof (upstreamStream as any).destroy === 'function') {
|
||||||
|
(upstreamStream as any).destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,7 +1519,14 @@ export class ProxyService {
|
|||||||
if (normalized === 'MAX_TOKENS') {
|
if (normalized === 'MAX_TOKENS') {
|
||||||
return 'length';
|
return 'length';
|
||||||
}
|
}
|
||||||
if (normalized === 'SAFETY' || normalized === 'RECITATION') {
|
if (
|
||||||
|
normalized === 'SAFETY' ||
|
||||||
|
normalized === 'RECITATION' ||
|
||||||
|
normalized === 'OTHER' ||
|
||||||
|
normalized === 'IMAGE_SAFETY' ||
|
||||||
|
normalized === 'PROHIBITED_CONTENT' ||
|
||||||
|
normalized === 'SPII'
|
||||||
|
) {
|
||||||
return 'content_filter';
|
return 'content_filter';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,6 +1613,11 @@ export class ProxyService {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let finalContent = textContent;
|
||||||
|
if (reasoningContent) {
|
||||||
|
finalContent = `<think>\n${reasoningContent}\n</think>\n\n${textContent}`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `chatcmpl-${uuidv4()}`,
|
id: `chatcmpl-${uuidv4()}`,
|
||||||
object: 'chat.completion',
|
object: 'chat.completion',
|
||||||
@@ -1517,9 +1628,8 @@ export class ProxyService {
|
|||||||
index: 0,
|
index: 0,
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: textContent || null,
|
content: finalContent ? finalContent : toolCalls.length > 0 ? null : '',
|
||||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||||
reasoning_content: reasoningContent || undefined,
|
|
||||||
},
|
},
|
||||||
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
||||||
claudeResponse.stop_reason,
|
claudeResponse.stop_reason,
|
||||||
@@ -1585,7 +1695,19 @@ export class ProxyService {
|
|||||||
|
|
||||||
if (error instanceof UpstreamRequestError) {
|
if (error instanceof UpstreamRequestError) {
|
||||||
const status = error.status;
|
const status = error.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401) {
|
||||||
|
this.logger.warn(`Upstream returned 401 for account ${accountId}; attempting token refresh`);
|
||||||
|
const refreshed = await this.tokenManager.forceRefreshToken(accountId);
|
||||||
|
if (refreshed) {
|
||||||
|
this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`Token refresh failed for account ${accountId}; marking as forbidden`);
|
||||||
|
this.tokenManager.markAsForbidden(accountId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
this.tokenManager.markAsForbidden(accountId);
|
this.tokenManager.markAsForbidden(accountId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,10 +363,7 @@ export class RateLimitTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isString(detail.metadata?.retryDelay) && !isEmpty(detail.metadata.retryDelay.trim())) {
|
||||||
isString(detail.metadata?.retryDelay) &&
|
|
||||||
!isEmpty(detail.metadata.retryDelay.trim())
|
|
||||||
) {
|
|
||||||
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
|
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
|
||||||
if (parsedDelay !== null) {
|
if (parsedDelay !== null) {
|
||||||
return parsedDelay;
|
return parsedDelay;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { isString } from 'lodash-es';
|
|||||||
import { getGeminiNexusVersion } from '../../../utils/geminiNexusVersion';
|
import { getGeminiNexusVersion } from '../../../utils/geminiNexusVersion';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
const REMOTE_VERSION_URL = 'https://geminiNexus-auto-updater-974169037036.us-central1.run.app';
|
const REMOTE_VERSION_URL = 'https://antigravity-auto-updater-974169037036.us-central1.run.app';
|
||||||
const CHANGELOG_URL = 'https://geminiNexus.google/changelog';
|
const CHANGELOG_URL = 'https://antigravity.google/changelog';
|
||||||
export const FALLBACK_VERSION = '1.22.2';
|
export const FALLBACK_VERSION = '1.22.2';
|
||||||
const DEFAULT_REMOTE_TIMEOUT_MS = 2500;
|
const DEFAULT_REMOTE_TIMEOUT_MS = 2500;
|
||||||
const VERSION_REGEX = /\d+\.\d+\.\d+/g;
|
const VERSION_REGEX = /\d+\.\d+\.\d+/g;
|
||||||
@@ -57,7 +57,7 @@ function getArchTag(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildUserAgent(version: string): string {
|
export function buildUserAgent(version: string): string {
|
||||||
return `geminiNexus/${version} ${getPlatformTag()}/${getArchTag()}`;
|
return `antigravity/${version} ${getPlatformTag()}/${getArchTag()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareSemverVersions(left: string, right: string): number {
|
function compareSemverVersions(left: string, right: string): number {
|
||||||
|
|||||||
@@ -181,6 +181,37 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
this.setAccountCooldown(accountIdOrEmail, 'forbidden', this.forbiddenCooldownMs);
|
this.setAccountCooldown(accountIdOrEmail, 'forbidden', this.forbiddenCooldownMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async forceRefreshToken(accountIdOrEmail: string): Promise<boolean> {
|
||||||
|
const accountId = this.resolveAccountId(accountIdOrEmail) ?? accountIdOrEmail;
|
||||||
|
const tokenData = this.tokens.get(accountId);
|
||||||
|
if (!tokenData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const newTokens = await GoogleAPIService.refreshAccessToken(
|
||||||
|
tokenData.refresh_token,
|
||||||
|
tokenData.upstream_proxy_url,
|
||||||
|
tokenData.oauth_client_key,
|
||||||
|
);
|
||||||
|
tokenData.access_token = newTokens.access_token;
|
||||||
|
tokenData.expires_in = newTokens.expires_in;
|
||||||
|
tokenData.expiry_timestamp = nowSeconds + newTokens.expires_in;
|
||||||
|
tokenData.oauth_client_key = this.normalizeRefreshedOauthClientKey(
|
||||||
|
tokenData,
|
||||||
|
newTokens.oauth_client_key,
|
||||||
|
);
|
||||||
|
await this.persistTokenState(accountId, tokenData);
|
||||||
|
this.tokens.set(accountId, tokenData);
|
||||||
|
this.logger.log(`Access token force-refreshed for ${tokenData.email}`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Failed to force-refresh access token for ${tokenData.email}`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async markFromUpstreamError(params: {
|
async markFromUpstreamError(params: {
|
||||||
accountIdOrEmail: string;
|
accountIdOrEmail: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -661,11 +692,7 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limitCandidate = modelInfo.max_output_tokens ?? modelInfo.max_tokens;
|
const limitCandidate = modelInfo.max_output_tokens ?? modelInfo.max_tokens;
|
||||||
if (
|
if (isNumber(limitCandidate) && Number.isFinite(limitCandidate) && limitCandidate > 0) {
|
||||||
isNumber(limitCandidate) &&
|
|
||||||
Number.isFinite(limitCandidate) &&
|
|
||||||
limitCandidate > 0
|
|
||||||
) {
|
|
||||||
modelLimits[normalizedModel] = Math.floor(limitCandidate);
|
modelLimits[normalizedModel] = Math.floor(limitCandidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mockTokenManager: any = {
|
|||||||
getNextToken: async () => null,
|
getNextToken: async () => null,
|
||||||
markAsRateLimited: () => undefined,
|
markAsRateLimited: () => undefined,
|
||||||
markAsForbidden: () => undefined,
|
markAsForbidden: () => undefined,
|
||||||
|
forceRefreshToken: async () => false,
|
||||||
markFromUpstreamError: () => undefined,
|
markFromUpstreamError: () => undefined,
|
||||||
recordParityError: () => undefined,
|
recordParityError: () => undefined,
|
||||||
};
|
};
|
||||||
@@ -241,6 +242,8 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
|||||||
realTokenManager.markAsRateLimited(accountIdOrEmail),
|
realTokenManager.markAsRateLimited(accountIdOrEmail),
|
||||||
markAsForbidden: (accountIdOrEmail: string) =>
|
markAsForbidden: (accountIdOrEmail: string) =>
|
||||||
realTokenManager.markAsForbidden(accountIdOrEmail),
|
realTokenManager.markAsForbidden(accountIdOrEmail),
|
||||||
|
forceRefreshToken: (accountIdOrEmail: string) =>
|
||||||
|
realTokenManager.forceRefreshToken(accountIdOrEmail),
|
||||||
markFromUpstreamError: (args: {
|
markFromUpstreamError: (args: {
|
||||||
accountIdOrEmail: string;
|
accountIdOrEmail: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -272,9 +275,7 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
|||||||
const captured = capturedBody as Record<string, unknown>;
|
const captured = capturedBody as Record<string, unknown>;
|
||||||
const tokenProjectId = selectedToken?.token?.project_id;
|
const tokenProjectId = selectedToken?.token?.project_id;
|
||||||
const normalizedTokenProjectId =
|
const normalizedTokenProjectId =
|
||||||
isString(tokenProjectId) && !isEmpty(tokenProjectId.trim())
|
isString(tokenProjectId) && !isEmpty(tokenProjectId.trim()) ? tokenProjectId.trim() : undefined;
|
||||||
? tokenProjectId.trim()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (normalizedTokenProjectId) {
|
if (normalizedTokenProjectId) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -249,9 +249,8 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||||
const { FALLBACK_VERSION, resolveLocalInstalledVersion } = await import(
|
const { FALLBACK_VERSION, resolveLocalInstalledVersion } =
|
||||||
'../../server/modules/proxy/request-user-agent'
|
await import('../../server/modules/proxy/request-user-agent');
|
||||||
);
|
|
||||||
const expectedVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
const expectedVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||||
|
|
||||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||||
@@ -279,13 +278,11 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||||
.fn()
|
ok: false,
|
||||||
.mockResolvedValueOnce({
|
status: 400,
|
||||||
ok: false,
|
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
||||||
status: 400,
|
});
|
||||||
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
@@ -405,9 +402,11 @@ describe('QuotaService fallback policy', () => {
|
|||||||
create: vi.fn(() => ({
|
create: vi.fn(() => ({
|
||||||
post: postMock,
|
post: postMock,
|
||||||
})),
|
})),
|
||||||
isAxiosError: (error: unknown) => Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
isAxiosError: (error: unknown) =>
|
||||||
|
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||||
},
|
},
|
||||||
isAxiosError: (error: unknown) => Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
isAxiosError: (error: unknown) =>
|
||||||
|
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { QuotaService } = await import('../../lib/geminiNexus/QuotaService');
|
const { QuotaService } = await import('../../lib/geminiNexus/QuotaService');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mockTokenManager = {
|
|||||||
getNextToken: vi.fn(),
|
getNextToken: vi.fn(),
|
||||||
markAsRateLimited: vi.fn(),
|
markAsRateLimited: vi.fn(),
|
||||||
markAsForbidden: vi.fn(),
|
markAsForbidden: vi.fn(),
|
||||||
|
forceRefreshToken: vi.fn(),
|
||||||
markFromUpstreamError: vi.fn(),
|
markFromUpstreamError: vi.fn(),
|
||||||
recordParityError: vi.fn(),
|
recordParityError: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -97,5 +98,4 @@ describe('Proxy Parity Fixtures', () => {
|
|||||||
expect(output).toContain(token);
|
expect(output).toContain(token);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ const mockTokenManager = {
|
|||||||
getNextToken: vi.fn(),
|
getNextToken: vi.fn(),
|
||||||
markAsRateLimited: vi.fn(),
|
markAsRateLimited: vi.fn(),
|
||||||
markAsForbidden: vi.fn(),
|
markAsForbidden: vi.fn(),
|
||||||
|
forceRefreshToken: vi.fn(),
|
||||||
markFromUpstreamError: vi.fn(),
|
markFromUpstreamError: vi.fn(),
|
||||||
recordParityError: vi.fn(),
|
recordParityError: vi.fn(),
|
||||||
getModelOutputLimitForAccount: vi.fn(),
|
getModelOutputLimitForAccount: vi.fn(),
|
||||||
|
|||||||
@@ -123,11 +123,7 @@ describe('TokenManagerService parity scheduling replay', () => {
|
|||||||
|
|
||||||
const selected = await (service as any).finalizeSelectedToken('acc-1', tokenData, nowSec);
|
const selected = await (service as any).finalizeSelectedToken('acc-1', tokenData, nowSec);
|
||||||
|
|
||||||
expect(refreshSpy).toHaveBeenCalledWith(
|
expect(refreshSpy).toHaveBeenCalledWith('refresh-1', 'http://127.0.0.1:8080', 'custom-client');
|
||||||
'refresh-1',
|
|
||||||
'http://127.0.0.1:8080',
|
|
||||||
'custom-client',
|
|
||||||
);
|
|
||||||
expect(selected?.token.oauth_client_key).toBe('custom-fallback');
|
expect(selected?.token.oauth_client_key).toBe('custom-fallback');
|
||||||
expect((service as any).tokens.get('acc-1')?.oauth_client_key).toBe('custom-fallback');
|
expect((service as any).tokens.get('acc-1')?.oauth_client_key).toBe('custom-fallback');
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -143,9 +143,7 @@ class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatArgs(args: unknown[]): string {
|
private formatArgs(args: unknown[]): string {
|
||||||
return args
|
return args.map((arg) => (isObjectLike(arg) ? safeStringify(arg) : String(arg))).join(' ');
|
||||||
.map((arg) => (isObjectLike(arg) ? safeStringify(arg) : String(arg)))
|
|
||||||
.join(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(level: LogLevel, message: string, ...args: unknown[]) {
|
log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||||
|
|||||||
+4
-1
@@ -85,7 +85,10 @@ export function getAppDataDir(): string {
|
|||||||
case 'darwin':
|
case 'darwin':
|
||||||
return path.join(home, 'Library', 'Application Support', 'Gemini Nexus');
|
return path.join(home, 'Library', 'Application Support', 'Gemini Nexus');
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Gemini Nexus');
|
return path.join(
|
||||||
|
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
||||||
|
'Gemini Nexus',
|
||||||
|
);
|
||||||
case 'linux':
|
case 'linux':
|
||||||
return path.join(home, '.config', 'Gemini Nexus');
|
return path.join(home, '.config', 'Gemini Nexus');
|
||||||
default:
|
default:
|
||||||
|
|||||||
+12
-3
@@ -113,7 +113,10 @@ async function readSafeStorageKey(keyPath: string): Promise<Buffer | null> {
|
|||||||
|
|
||||||
return Buffer.from(hexKey, 'hex');
|
return Buffer.from(hexKey, 'hex');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Security: Decryption of safeStorage key file failed. Keyring might be locked.', error);
|
logger.error(
|
||||||
|
'Security: Decryption of safeStorage key file failed. Keyring might be locked.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,9 +306,15 @@ async function generatePrimaryMasterKey(): Promise<MasterKeyState> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If we failed to decrypt but the file exists, we should NOT proceed to other fallbacks
|
// If we failed to decrypt but the file exists, we should NOT proceed to other fallbacks
|
||||||
// as they might overwrite the existing file and cause permanent data loss.
|
// as they might overwrite the existing file and cause permanent data loss.
|
||||||
const fileExists = await fs.access(keyPath).then(() => true).catch(() => false);
|
const fileExists = await fs
|
||||||
|
.access(keyPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
logger.error('Security: safeStorage key file exists but decryption failed. Keyring might be locked. Stopping to prevent data loss.', error);
|
logger.error(
|
||||||
|
'Security: safeStorage key file exists but decryption failed. Keyring might be locked. Stopping to prevent data loss.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
logger.warn('Security: safeStorage failed, trying keytar', error);
|
logger.warn('Security: safeStorage failed, trying keytar', error);
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { isObjectLike } from 'lodash-es';
|
||||||
|
import winston from 'winston';
|
||||||
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||||
|
import { getAgentDir } from './paths';
|
||||||
|
|
||||||
|
export type TrafficDirection = 'inbound' | 'outbound' | 'upstream' | 'upstream_response' | 'sse_chunk' | 'error';
|
||||||
|
|
||||||
|
export interface TrafficLogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
direction: TrafficDirection;
|
||||||
|
requestId: string;
|
||||||
|
endpoint: string;
|
||||||
|
method?: string;
|
||||||
|
status?: number;
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
chunk?: unknown;
|
||||||
|
durationMs?: number;
|
||||||
|
error?: {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
status?: number;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_RETENTION = '30d';
|
||||||
|
const LOG_MAX_SIZE = '50m';
|
||||||
|
|
||||||
|
function safeStringify(obj: unknown): string {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
return JSON.stringify(obj, (key, value) => {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: value.name,
|
||||||
|
message: value.message,
|
||||||
|
stack: value.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isObjectLike(value)) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return '[Circular]';
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrafficLogger {
|
||||||
|
private winstonLogger: winston.Logger;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const agentDir = getAgentDir();
|
||||||
|
const logsDir = path.join(agentDir, 'traffic');
|
||||||
|
|
||||||
|
if (!fs.existsSync(logsDir)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create traffic logs directory', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.printf(({ timestamp, level, message }) => {
|
||||||
|
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotateTransport = new DailyRotateFile({
|
||||||
|
filename: path.join(logsDir, 'api-traffic-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD-HH',
|
||||||
|
maxSize: LOG_MAX_SIZE,
|
||||||
|
maxFiles: LOG_RETENTION,
|
||||||
|
zippedArchive: true,
|
||||||
|
auditFile: path.join(logsDir, '.api-traffic-log-audit.json'),
|
||||||
|
level: 'debug',
|
||||||
|
format: fileFormat,
|
||||||
|
});
|
||||||
|
|
||||||
|
rotateTransport.on('error', (error) => {
|
||||||
|
console.error('Traffic DailyRotateFile transport error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.winstonLogger = winston.createLogger({
|
||||||
|
level: 'debug',
|
||||||
|
transports: [rotateTransport],
|
||||||
|
exitOnError: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(entry: TrafficLogEntry): void {
|
||||||
|
const line = safeStringify(entry);
|
||||||
|
this.winstonLogger.log({
|
||||||
|
level: 'debug',
|
||||||
|
message: line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logInbound(requestId: string, endpoint: string, body: unknown, headers?: Record<string, unknown>, metadata?: Record<string, unknown>): void {
|
||||||
|
this.log({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'inbound',
|
||||||
|
requestId,
|
||||||
|
endpoint,
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.sanitizeHeaders(headers),
|
||||||
|
body: this.truncateBody(body),
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logOutbound(requestId: string, endpoint: string, status: number, body: unknown, durationMs: number, headers?: Record<string, unknown>): void {
|
||||||
|
this.log({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'outbound',
|
||||||
|
requestId,
|
||||||
|
endpoint,
|
||||||
|
status,
|
||||||
|
headers: this.sanitizeHeaders(headers),
|
||||||
|
body: this.truncateBody(body),
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logUpstream(requestId: string, endpoint: string, body: unknown, headers?: Record<string, unknown>, metadata?: Record<string, unknown>): void {
|
||||||
|
this.log({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'upstream',
|
||||||
|
requestId,
|
||||||
|
endpoint,
|
||||||
|
headers: this.sanitizeHeaders(headers),
|
||||||
|
body: this.truncateBody(body),
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logUpstreamResponse(requestId: string, endpoint: string, status: number, body: unknown, durationMs: number, headers?: Record<string, unknown>): void {
|
||||||
|
this.log({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'upstream_response',
|
||||||
|
requestId,
|
||||||
|
endpoint,
|
||||||
|
status,
|
||||||
|
headers: this.sanitizeHeaders(headers),
|
||||||
|
body: this.truncateBody(body),
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logSseChunk(requestId: string, endpoint: string, chunk: unknown, metadata?: Record<string, unknown>): void {
|
||||||
|
this.log({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'sse_chunk',
|
||||||
|
requestId,
|
||||||
|
endpoint,
|
||||||
|
chunk: this.truncateBody(chunk),
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(requestId: string, endpoint: string, error: Error | unknown, status?: number, body?: unknown, metadata?: Record<string, unknown>): void {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.log({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'error',
|
||||||
|
requestId,
|
||||||
|
endpoint,
|
||||||
|
status,
|
||||||
|
error: {
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
status,
|
||||||
|
body: this.truncateBody(body),
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeHeaders(headers?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||||
|
if (!headers) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey === 'authorization' || lowerKey === 'x-api-key' || lowerKey === 'cookie') {
|
||||||
|
sanitized[key] = '[REDACTED]';
|
||||||
|
} else {
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private truncateBody(body: unknown, maxChars = 500_000): unknown {
|
||||||
|
if (typeof body === 'string' && body.length > maxChars) {
|
||||||
|
return body.slice(0, maxChars) + `... [truncated ${body.length - maxChars} chars]`;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trafficLogger = new TrafficLogger();
|
||||||
Reference in new issue
Block a user