Skip to content

Commit a2e7614

Browse files
Merge pull request #58 from browseros-ai/http-server-gemini
Http server and Session manager
2 parents 6fe4b79 + 5fd4464 commit a2e7614

File tree

18 files changed

+451
-1505
lines changed

18 files changed

+451
-1505
lines changed

bun.lock

Lines changed: 79 additions & 2 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
"smol-toml": "^1.4.2"
5959
},
6060
"devDependencies": {
61+
"@ai-sdk/provider": "2.0.0",
62+
"@ai-sdk/ui-utils": "^1.2.11",
6163
"@eslint/js": "^9.35.0",
6264
"@modelcontextprotocol/sdk": "1.20.0",
6365
"@stylistic/eslint-plugin": "^5.4.0",
@@ -69,6 +71,7 @@
6971
"@types/sinon": "^17.0.4",
7072
"@typescript-eslint/eslint-plugin": "^8.43.0",
7173
"@typescript-eslint/parser": "^8.43.0",
74+
"ai": "^5.0.102",
7275
"async-mutex": "^0.5.0",
7376
"chrome-devtools-frontend": "1.0.1524741",
7477
"commander": "^14.0.1",

packages/agent/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,22 @@
3434
"@ai-sdk/google": "^2.0.43",
3535
"@ai-sdk/openai": "^2.0.72",
3636
"@ai-sdk/openai-compatible": "^1.0.27",
37+
"@ai-sdk/provider": "2.0.0",
38+
"@ai-sdk/ui-utils": "^1.2.11",
3739
"@anthropic-ai/claude-agent-sdk": "^0.1.11",
3840
"@browseros/common": "workspace:*",
3941
"@browseros/server": "workspace:*",
4042
"@browseros/tools": "workspace:*",
4143
"@google/gemini-cli-core": "^0.16.0",
44+
"@hono/node-server": "^1.19.6",
4245
"@openrouter/ai-sdk-provider": "~1.2.5",
4346
"ai": "^5.0.101",
4447
"zod": "^4.1.12"
4548
},
4649
"devDependencies": {
4750
"@types/bun": "latest",
48-
"typescript": "^5.9.3"
51+
"typescript": "^5.9.3",
52+
"vitest": "^4.0.14"
4953
},
5054
"optionalDependencies": {
5155
"chrome-devtools-mcp": "latest"

packages/agent/src/agent/GeminiAgent.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,6 @@ export class GeminiAgent {
9494
});
9595

9696
await geminiConfig.initialize();
97-
98-
console.log('resolvedConfig', resolvedConfig);
9997
const contentGenerator = new VercelAIContentGenerator(resolvedConfig);
10098

10199
(geminiConfig as unknown as { contentGenerator: VercelAIContentGenerator }).contentGenerator = contentGenerator;

packages/agent/src/agent/gemini-vercel-sdk-adapter/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class VercelAIContentGenerator implements ContentGenerator {
122122
tools,
123123
temperature: request.config?.temperature,
124124
topP: request.config?.topP,
125+
abortSignal: request.config?.abortSignal,
125126
});
126127

127128
return this.responseStrategy.streamToGemini(

packages/agent/src/agent/gemini-vercel-sdk-adapter/strategies/response.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,25 @@ export class ResponseConversionStrategy {
186186
usage = this.estimateUsage(textAccumulator);
187187
}
188188

189+
// Emit finish stream part to Hono SSE for useChat compatibility
190+
if (honoStream) {
191+
try {
192+
// Emit finish_message part with finishReason and usage
193+
// Format: e:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}}
194+
// Map to LanguageModelV1FinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown'
195+
const mappedFinishReason = this.mapToDataStreamFinishReason(finishReason);
196+
await honoStream.write(formatDataStreamPart('finish_message', {
197+
finishReason: mappedFinishReason,
198+
usage: usage ? {
199+
promptTokens: usage.promptTokens ?? 0,
200+
completionTokens: usage.completionTokens ?? 0,
201+
} : undefined,
202+
}));
203+
} catch {
204+
// Failed to write finish part
205+
}
206+
}
207+
189208
// Yield final response with tool calls and metadata
190209
if (toolCallsMap.size > 0 || finishReason || usage) {
191210
const parts: Part[] = [];
@@ -281,6 +300,19 @@ export class ResponseConversionStrategy {
281300
}
282301
}
283302

303+
/**
304+
* Map Vercel finish reasons to data stream protocol finish reasons
305+
* LanguageModelV1FinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown'
306+
* Mostly passthrough except 'max-tokens' → 'length'
307+
*/
308+
private mapToDataStreamFinishReason(
309+
reason: VercelFinishReason | undefined,
310+
): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown' {
311+
if (!reason) return 'stop';
312+
if (reason === 'max-tokens') return 'length';
313+
return reason;
314+
}
315+
284316
/**
285317
* Create empty response for error cases
286318
*/

packages/agent/src/errors.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
export class HttpAgentError extends Error {
2+
constructor(
3+
message: string,
4+
public statusCode: number = 500,
5+
public code?: string,
6+
) {
7+
super(message);
8+
this.name = this.constructor.name;
9+
Error.captureStackTrace(this, this.constructor);
10+
}
11+
12+
toJSON() {
13+
return {
14+
error: {
15+
name: this.name,
16+
message: this.message,
17+
code: this.code,
18+
statusCode: this.statusCode,
19+
},
20+
};
21+
}
22+
}
23+
24+
export class ValidationError extends HttpAgentError {
25+
constructor(message: string, public details?: unknown) {
26+
super(message, 400, 'VALIDATION_ERROR');
27+
}
28+
29+
override toJSON() {
30+
return {
31+
error: {
32+
name: this.name,
33+
message: this.message,
34+
code: this.code,
35+
statusCode: this.statusCode,
36+
details: this.details,
37+
},
38+
};
39+
}
40+
}
41+
42+
export class SessionNotFoundError extends HttpAgentError {
43+
constructor(public conversationId: string) {
44+
super(`Session "${conversationId}" not found.`, 404, 'SESSION_NOT_FOUND');
45+
}
46+
}
47+
48+
export class AgentExecutionError extends HttpAgentError {
49+
constructor(message: string, public originalError?: Error) {
50+
super(message, 500, 'AGENT_EXECUTION_ERROR');
51+
}
52+
53+
override toJSON() {
54+
return {
55+
error: {
56+
name: this.name,
57+
message: this.message,
58+
code: this.code,
59+
statusCode: this.statusCode,
60+
originalError: this.originalError?.message,
61+
},
62+
};
63+
}
64+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { Hono } from 'hono';
2+
import { cors } from 'hono/cors';
3+
import { stream } from 'hono/streaming';
4+
import { serve } from '@hono/node-server';
5+
import { formatDataStreamPart } from '@ai-sdk/ui-utils';
6+
import { logger } from '@browseros/common';
7+
import type { Context, Next } from 'hono';
8+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
9+
import type { z } from 'zod';
10+
11+
import { SessionManager } from '../session/SessionManager.js';
12+
import { HttpAgentError, ValidationError, AgentExecutionError } from '../errors.js';
13+
import { ChatRequestSchema, HttpServerConfigSchema } from './types.js';
14+
import type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './types.js';
15+
16+
type AppVariables = {
17+
validatedBody: unknown;
18+
};
19+
20+
const DEFAULT_MCP_SERVER_URL = 'http://127.0.0.1:9150/mcp';
21+
const DEFAULT_TEMP_DIR = '/tmp';
22+
23+
function validateRequest<T>(schema: z.ZodType<T>) {
24+
return async (c: Context<{ Variables: AppVariables }>, next: Next) => {
25+
try {
26+
const body = await c.req.json();
27+
const validated = schema.parse(body);
28+
c.set('validatedBody', validated);
29+
await next();
30+
} catch (err) {
31+
if (err && typeof err === 'object' && 'issues' in err) {
32+
const zodError = err as { issues: unknown };
33+
logger.warn('Request validation failed', { issues: zodError.issues });
34+
throw new ValidationError('Request validation failed', zodError.issues);
35+
}
36+
throw err;
37+
}
38+
};
39+
}
40+
41+
export function createHttpServer(config: HttpServerConfig) {
42+
const validatedConfig: ValidatedHttpServerConfig = HttpServerConfigSchema.parse(config);
43+
const mcpServerUrl = validatedConfig.mcpServerUrl || process.env.MCP_SERVER_URL || DEFAULT_MCP_SERVER_URL;
44+
45+
const app = new Hono<{ Variables: AppVariables }>();
46+
const sessionManager = new SessionManager();
47+
48+
app.use(
49+
'/*',
50+
cors({
51+
origin: (origin) => origin || '*',
52+
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
53+
allowHeaders: ['Content-Type', 'Authorization'],
54+
credentials: true,
55+
}),
56+
);
57+
58+
app.onError((err, c) => {
59+
const error = err as Error;
60+
61+
if (error instanceof HttpAgentError) {
62+
logger.warn('HTTP Agent Error', {
63+
name: error.name,
64+
message: error.message,
65+
code: error.code,
66+
statusCode: error.statusCode,
67+
});
68+
return c.json(error.toJSON(), error.statusCode as ContentfulStatusCode);
69+
}
70+
71+
logger.error('Unhandled Error', {
72+
message: error.message,
73+
stack: error.stack,
74+
});
75+
76+
return c.json(
77+
{
78+
error: {
79+
name: 'InternalServerError',
80+
message: error.message || 'An unexpected error occurred',
81+
code: 'INTERNAL_SERVER_ERROR',
82+
statusCode: 500,
83+
},
84+
},
85+
500,
86+
);
87+
});
88+
89+
app.get('/health', (c) => c.json({ status: 'ok' }));
90+
91+
app.post('/chat', validateRequest(ChatRequestSchema), async (c) => {
92+
const request = c.get('validatedBody') as ChatRequest;
93+
94+
logger.info('Chat request received', {
95+
conversationId: request.conversationId,
96+
provider: request.provider,
97+
model: request.model,
98+
});
99+
100+
c.header('Content-Type', 'text/plain; charset=utf-8');
101+
c.header('X-Vercel-AI-Data-Stream', 'v1');
102+
c.header('Cache-Control', 'no-cache');
103+
c.header('Connection', 'keep-alive');
104+
105+
// Get abort signal from the raw request - fires when client disconnects
106+
const abortSignal = c.req.raw.signal;
107+
108+
return stream(c, async (honoStream) => {
109+
try {
110+
const agent = await sessionManager.getOrCreate({
111+
conversationId: request.conversationId,
112+
provider: request.provider,
113+
model: request.model,
114+
apiKey: request.apiKey,
115+
baseUrl: request.baseUrl,
116+
// Azure-specific
117+
resourceName: request.resourceName,
118+
// AWS Bedrock-specific
119+
region: request.region,
120+
accessKeyId: request.accessKeyId,
121+
secretAccessKey: request.secretAccessKey,
122+
sessionToken: request.sessionToken,
123+
// Agent-specific
124+
tempDir: validatedConfig.tempDir || DEFAULT_TEMP_DIR,
125+
mcpServerUrl,
126+
});
127+
128+
await agent.execute(request.message, honoStream, abortSignal);
129+
} catch (error) {
130+
const errorMessage = error instanceof Error ? error.message : 'Agent execution failed';
131+
logger.error('Agent execution error', {
132+
conversationId: request.conversationId,
133+
error: errorMessage,
134+
});
135+
await honoStream.write(formatDataStreamPart('error', errorMessage));
136+
throw new AgentExecutionError('Agent execution failed', error instanceof Error ? error : undefined);
137+
}
138+
});
139+
});
140+
141+
app.delete('/chat/:conversationId', (c) => {
142+
const conversationId = c.req.param('conversationId');
143+
const deleted = sessionManager.delete(conversationId);
144+
145+
if (deleted) {
146+
return c.json({
147+
success: true,
148+
message: `Session ${conversationId} deleted`,
149+
sessionCount: sessionManager.count(),
150+
});
151+
}
152+
153+
return c.json({
154+
success: false,
155+
message: `Session ${conversationId} not found`,
156+
}, 404);
157+
});
158+
159+
const server = serve({
160+
fetch: app.fetch,
161+
port: validatedConfig.port,
162+
hostname: validatedConfig.host,
163+
});
164+
165+
logger.info('HTTP Agent Server started', {
166+
port: validatedConfig.port,
167+
host: validatedConfig.host,
168+
});
169+
170+
return {
171+
app,
172+
server,
173+
config: validatedConfig,
174+
};
175+
}

packages/agent/src/http/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { createHttpServer } from './HttpServer.js';
2+
export { HttpServerConfigSchema, ChatRequestSchema } from './types.js';
3+
export type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './types.js';

packages/agent/src/http/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { z } from 'zod';
2+
import { VercelAIConfigSchema } from '../agent/gemini-vercel-sdk-adapter/types.js';
3+
4+
/**
5+
* Chat request schema extends VercelAIConfig with request-specific fields
6+
*/
7+
export const ChatRequestSchema = VercelAIConfigSchema.extend({
8+
conversationId: z.string().uuid(),
9+
message: z.string().min(1, 'Message cannot be empty'),
10+
});
11+
12+
export type ChatRequest = z.infer<typeof ChatRequestSchema>;
13+
14+
export interface HttpServerConfig {
15+
port: number;
16+
host?: string;
17+
corsOrigins?: string[];
18+
tempDir?: string;
19+
mcpServerUrl?: string;
20+
}
21+
22+
export const HttpServerConfigSchema = z.object({
23+
port: z.number().int().positive(),
24+
host: z.string().optional().default('0.0.0.0'),
25+
corsOrigins: z.array(z.string()).optional().default(['*']),
26+
tempDir: z.string().optional().default('/tmp'),
27+
mcpServerUrl: z.string().optional(),
28+
});
29+
30+
export type ValidatedHttpServerConfig = z.infer<typeof HttpServerConfigSchema>;

0 commit comments

Comments
 (0)