Skip to content

Commit 623696a

Browse files
committed
refactor: modularize fetch wrapper and extract shared state into separate modules
- Split monolithic index.ts into focused modules for better maintainability - Extract fetch wrapper into lib/fetch-wrapper/ with format-specific handlers: - openai-chat.ts: OpenAI Chat Completions & Anthropic format - openai-responses.ts: OpenAI Responses API format (GPT-5) - gemini.ts: Google/Gemini format with position-based correlation - types.ts: Shared types and utility functions - Create lib/state.ts for centralized plugin state management - Create lib/hooks.ts for event and chat.params handlers - Create lib/pruning-tool.ts for context_pruning tool definition - Create lib/tool-cache.ts for tool parameter caching utilities - Reduce index.ts from 500+ lines to ~80 lines of initialization code
1 parent 8083b33 commit 623696a

File tree

10 files changed

+804
-520
lines changed

10 files changed

+804
-520
lines changed

index.ts

Lines changed: 35 additions & 520 deletions
Large diffs are not rendered by default.

lib/fetch-wrapper/gemini.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { FetchHandlerContext, FetchHandlerResult } from "./types"
2+
import {
3+
PRUNED_CONTENT_MESSAGE,
4+
getAllPrunedIds,
5+
fetchSessionMessages
6+
} from "./types"
7+
8+
/**
9+
* Handles Google/Gemini format (body.contents array with functionResponse parts).
10+
* Uses position-based correlation since Google's native format doesn't include tool call IDs.
11+
*/
12+
export async function handleGemini(
13+
body: any,
14+
ctx: FetchHandlerContext,
15+
inputUrl: string
16+
): Promise<FetchHandlerResult> {
17+
if (!body.contents || !Array.isArray(body.contents)) {
18+
return { modified: false, body }
19+
}
20+
21+
// Check for functionResponse parts in any content item
22+
const hasFunctionResponses = body.contents.some((content: any) =>
23+
Array.isArray(content.parts) &&
24+
content.parts.some((part: any) => part.functionResponse)
25+
)
26+
27+
if (!hasFunctionResponses) {
28+
return { modified: false, body }
29+
}
30+
31+
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)
32+
33+
if (allPrunedIds.size === 0) {
34+
return { modified: false, body }
35+
}
36+
37+
// Find the active session to get the position mapping
38+
const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || []
39+
let positionMapping: Map<string, string> | undefined
40+
41+
for (const session of activeSessions) {
42+
const mapping = ctx.state.googleToolCallMapping.get(session.id)
43+
if (mapping && mapping.size > 0) {
44+
positionMapping = mapping
45+
break
46+
}
47+
}
48+
49+
if (!positionMapping) {
50+
ctx.logger.info("fetch", "No Google tool call mapping found, skipping pruning for Gemini format")
51+
return { modified: false, body }
52+
}
53+
54+
// Build position counters to track occurrence of each tool name
55+
const toolPositionCounters = new Map<string, number>()
56+
let replacedCount = 0
57+
let totalFunctionResponses = 0
58+
59+
body.contents = body.contents.map((content: any) => {
60+
if (!Array.isArray(content.parts)) return content
61+
62+
let contentModified = false
63+
const newParts = content.parts.map((part: any) => {
64+
if (part.functionResponse) {
65+
totalFunctionResponses++
66+
const funcName = part.functionResponse.name?.toLowerCase()
67+
68+
if (funcName) {
69+
// Get current position for this tool name and increment counter
70+
const currentIndex = toolPositionCounters.get(funcName) || 0
71+
toolPositionCounters.set(funcName, currentIndex + 1)
72+
73+
// Look up the tool call ID using position
74+
const positionKey = `${funcName}:${currentIndex}`
75+
const toolCallId = positionMapping!.get(positionKey)
76+
77+
if (toolCallId && allPrunedIds.has(toolCallId)) {
78+
contentModified = true
79+
replacedCount++
80+
// Preserve thoughtSignature if present (required for Gemini 3 Pro)
81+
// Only replace the response content, not the structure
82+
return {
83+
...part,
84+
functionResponse: {
85+
...part.functionResponse,
86+
response: PRUNED_CONTENT_MESSAGE
87+
}
88+
}
89+
}
90+
}
91+
}
92+
return part
93+
})
94+
95+
if (contentModified) {
96+
return { ...content, parts: newParts }
97+
}
98+
return content
99+
})
100+
101+
if (replacedCount > 0) {
102+
ctx.logger.info("fetch", "Replaced pruned tool outputs (Google/Gemini)", {
103+
replaced: replacedCount,
104+
total: totalFunctionResponses
105+
})
106+
107+
if (ctx.logger.enabled) {
108+
let sessionMessages: any[] | undefined
109+
if (activeSessions.length > 0) {
110+
const mostRecentSession = activeSessions[0]
111+
sessionMessages = await fetchSessionMessages(ctx.client, mostRecentSession.id)
112+
}
113+
114+
await ctx.logger.saveWrappedContext(
115+
"global",
116+
body.contents,
117+
{
118+
url: inputUrl,
119+
replacedCount,
120+
totalContents: body.contents.length,
121+
format: 'google-gemini'
122+
},
123+
sessionMessages
124+
)
125+
}
126+
127+
return { modified: true, body }
128+
}
129+
130+
return { modified: false, body }
131+
}

lib/fetch-wrapper/index.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { PluginState } from "../state"
2+
import type { Logger } from "../logger"
3+
import type { FetchHandlerContext } from "./types"
4+
import { handleOpenAIChatAndAnthropic } from "./openai-chat"
5+
import { handleGemini } from "./gemini"
6+
import { handleOpenAIResponses } from "./openai-responses"
7+
8+
export type { FetchHandlerContext, FetchHandlerResult } from "./types"
9+
10+
/**
11+
* Creates a wrapped global fetch that intercepts API calls and performs
12+
* context pruning on tool outputs that have been marked for removal.
13+
*
14+
* Supports four API formats:
15+
* 1. OpenAI Chat Completions (body.messages with role='tool')
16+
* 2. Anthropic (body.messages with role='user' containing tool_result)
17+
* 3. Google/Gemini (body.contents with functionResponse parts)
18+
* 4. OpenAI Responses API (body.input with function_call_output items)
19+
*/
20+
export function installFetchWrapper(
21+
state: PluginState,
22+
logger: Logger,
23+
client: any
24+
): () => void {
25+
const originalGlobalFetch = globalThis.fetch
26+
27+
const ctx: FetchHandlerContext = {
28+
state,
29+
logger,
30+
client
31+
}
32+
33+
globalThis.fetch = async (input: any, init?: any) => {
34+
if (init?.body && typeof init.body === 'string') {
35+
try {
36+
const body = JSON.parse(init.body)
37+
const inputUrl = typeof input === 'string' ? input : 'URL object'
38+
let modified = false
39+
40+
// Try each format handler in order
41+
// OpenAI Chat Completions & Anthropic style (body.messages)
42+
if (body.messages && Array.isArray(body.messages)) {
43+
const result = await handleOpenAIChatAndAnthropic(body, ctx, inputUrl)
44+
if (result.modified) {
45+
modified = true
46+
}
47+
}
48+
49+
// Google/Gemini style (body.contents)
50+
if (body.contents && Array.isArray(body.contents)) {
51+
const result = await handleGemini(body, ctx, inputUrl)
52+
if (result.modified) {
53+
modified = true
54+
}
55+
}
56+
57+
// OpenAI Responses API style (body.input)
58+
if (body.input && Array.isArray(body.input)) {
59+
const result = await handleOpenAIResponses(body, ctx, inputUrl)
60+
if (result.modified) {
61+
modified = true
62+
}
63+
}
64+
65+
if (modified) {
66+
init.body = JSON.stringify(body)
67+
}
68+
} catch (e) {
69+
// Silently ignore parsing errors - pass through to original fetch
70+
}
71+
}
72+
73+
return originalGlobalFetch(input, init)
74+
}
75+
76+
// Return cleanup function to restore original fetch
77+
return () => {
78+
globalThis.fetch = originalGlobalFetch
79+
}
80+
}

lib/fetch-wrapper/openai-chat.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { FetchHandlerContext, FetchHandlerResult } from "./types"
2+
import {
3+
PRUNED_CONTENT_MESSAGE,
4+
getAllPrunedIds,
5+
fetchSessionMessages,
6+
getMostRecentActiveSession
7+
} from "./types"
8+
import { cacheToolParametersFromMessages } from "../tool-cache"
9+
10+
/**
11+
* Handles OpenAI Chat Completions format (body.messages with role='tool').
12+
* Also handles Anthropic format (role='user' with tool_result content parts).
13+
*/
14+
export async function handleOpenAIChatAndAnthropic(
15+
body: any,
16+
ctx: FetchHandlerContext,
17+
inputUrl: string
18+
): Promise<FetchHandlerResult> {
19+
if (!body.messages || !Array.isArray(body.messages)) {
20+
return { modified: false, body }
21+
}
22+
23+
// Cache tool parameters from messages
24+
cacheToolParametersFromMessages(body.messages, ctx.state)
25+
26+
// Check for tool messages in both formats:
27+
// 1. OpenAI style: role === 'tool'
28+
// 2. Anthropic style: role === 'user' with content containing tool_result
29+
const toolMessages = body.messages.filter((m: any) => {
30+
if (m.role === 'tool') return true
31+
if (m.role === 'user' && Array.isArray(m.content)) {
32+
for (const part of m.content) {
33+
if (part.type === 'tool_result') return true
34+
}
35+
}
36+
return false
37+
})
38+
39+
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)
40+
41+
if (toolMessages.length === 0 || allPrunedIds.size === 0) {
42+
return { modified: false, body }
43+
}
44+
45+
let replacedCount = 0
46+
47+
body.messages = body.messages.map((m: any) => {
48+
// OpenAI style: role === 'tool' with tool_call_id
49+
if (m.role === 'tool' && allPrunedIds.has(m.tool_call_id?.toLowerCase())) {
50+
replacedCount++
51+
return {
52+
...m,
53+
content: PRUNED_CONTENT_MESSAGE
54+
}
55+
}
56+
57+
// Anthropic style: role === 'user' with content array containing tool_result
58+
if (m.role === 'user' && Array.isArray(m.content)) {
59+
let messageModified = false
60+
const newContent = m.content.map((part: any) => {
61+
if (part.type === 'tool_result' && allPrunedIds.has(part.tool_use_id?.toLowerCase())) {
62+
messageModified = true
63+
replacedCount++
64+
return {
65+
...part,
66+
content: PRUNED_CONTENT_MESSAGE
67+
}
68+
}
69+
return part
70+
})
71+
if (messageModified) {
72+
return { ...m, content: newContent }
73+
}
74+
}
75+
76+
return m
77+
})
78+
79+
if (replacedCount > 0) {
80+
ctx.logger.info("fetch", "Replaced pruned tool outputs", {
81+
replaced: replacedCount,
82+
total: toolMessages.length
83+
})
84+
85+
if (ctx.logger.enabled) {
86+
const mostRecentSession = getMostRecentActiveSession(allSessions)
87+
const sessionMessages = mostRecentSession
88+
? await fetchSessionMessages(ctx.client, mostRecentSession.id)
89+
: undefined
90+
91+
await ctx.logger.saveWrappedContext(
92+
"global",
93+
body.messages,
94+
{
95+
url: inputUrl,
96+
replacedCount,
97+
totalMessages: body.messages.length
98+
},
99+
sessionMessages
100+
)
101+
}
102+
103+
return { modified: true, body }
104+
}
105+
106+
return { modified: false, body }
107+
}

0 commit comments

Comments
 (0)