@janux/agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/package.json +44 -0
- package/src/agent.test.ts +157 -0
- package/src/agent.ts +113 -0
- package/src/index.ts +10 -0
- package/src/model.ts +68 -0
- package/src/providers.ts +169 -0
package/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# @janux/agent
|
|
2
|
+
|
|
3
|
+
Embedded agent runtime for [Janux](https://github.com/aralroca/Janux): zero-config model resolution (`JANUX_MODEL` or provider API key sniffing), Anthropic/OpenAI/Google providers, and the tool loop that executes `api.*` tools server-side and returns `ui_calls` for the client bridge.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@janux/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Janux embedded agent runtime: zero-config model resolution, provider routing and the ui/api tool loop.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/aralroca/Janux.git"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Aral Roca Gomez",
|
|
12
|
+
"email": "contact@aralroca.com"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"janux": "0.1.0",
|
|
21
|
+
"@janux/server": "0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/aralroca/Janux#readme",
|
|
24
|
+
"bugs": "https://github.com/aralroca/Janux/issues",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"janux",
|
|
27
|
+
"agentic-ui",
|
|
28
|
+
"mcp",
|
|
29
|
+
"webmcp",
|
|
30
|
+
"ai-agents",
|
|
31
|
+
"fullstack",
|
|
32
|
+
"framework",
|
|
33
|
+
"bun",
|
|
34
|
+
"vite",
|
|
35
|
+
"resumability",
|
|
36
|
+
"copilot"
|
|
37
|
+
],
|
|
38
|
+
"files": [
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { component, intent, jsx, schema, str } from 'janux';
|
|
3
|
+
import { api, createJanuxServer } from '@janux/server';
|
|
4
|
+
import { resolveModel } from './model';
|
|
5
|
+
import { defineAgent } from './agent';
|
|
6
|
+
|
|
7
|
+
describe('model resolution (RFC §8.1)', () => {
|
|
8
|
+
const env = { ANTHROPIC_API_KEY: 'sk-a', OPENAI_API_KEY: 'sk-o' };
|
|
9
|
+
|
|
10
|
+
it('explicit model wins', () => {
|
|
11
|
+
const model = resolveModel('openai/gpt-5.2', env)!;
|
|
12
|
+
|
|
13
|
+
expect(model).toMatchObject({ provider: 'openai', model: 'gpt-5.2', source: 'defineAgent({ model })' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('JANUX_MODEL env comes second', () => {
|
|
17
|
+
const model = resolveModel(undefined, { ...env, JANUX_MODEL: 'anthropic/claude-fable-5' })!;
|
|
18
|
+
|
|
19
|
+
expect(model).toMatchObject({ provider: 'anthropic', model: 'claude-fable-5', source: 'JANUX_MODEL' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sniffs the provider from the first available API key', () => {
|
|
23
|
+
const model = resolveModel(undefined, { OPENAI_API_KEY: 'sk-o' })!;
|
|
24
|
+
|
|
25
|
+
expect(model.provider).toBe('openai');
|
|
26
|
+
expect(model.source).toContain('OPENAI_API_KEY');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns undefined with no keys (setup card path)', () => {
|
|
30
|
+
expect(resolveModel(undefined, {})).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function anthropicReply(blocks: unknown[]): Response {
|
|
35
|
+
return new Response(JSON.stringify({ content: blocks }), { status: 200 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scriptedFetch(replies: Response[]) {
|
|
39
|
+
const calls: { url: string; body: any }[] = [];
|
|
40
|
+
const fetchImpl = async (url: string, init: RequestInit) => {
|
|
41
|
+
calls.push({ url, body: JSON.parse(String(init.body)) });
|
|
42
|
+
|
|
43
|
+
return replies.shift() ?? anthropicReply([{ type: 'text', text: 'done' }]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { fetchImpl, calls };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const counter = component({
|
|
50
|
+
name: 'counter',
|
|
51
|
+
state: schema({ label: str() }),
|
|
52
|
+
intents: { rename: intent({ input: schema({ label: str() }), run: () => {} }) },
|
|
53
|
+
view: () => jsx('p', { children: 'hi' }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function buildServer(agent: ReturnType<typeof defineAgent>) {
|
|
57
|
+
return createJanuxServer({
|
|
58
|
+
routes: { '/': () => jsx(counter as any, {}) },
|
|
59
|
+
apis: {
|
|
60
|
+
shop: {
|
|
61
|
+
search: api({ input: schema({ q: str() }), run: ({ input }) => [`found:${input.q}`] }),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
agent,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ask = (server: ReturnType<typeof buildServer>, body: unknown) =>
|
|
69
|
+
server.fetch(
|
|
70
|
+
new Request('http://test/_janux/agent', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
headers: { 'content-type': 'application/json' },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
describe('agent loop', () => {
|
|
78
|
+
const env = { ANTHROPIC_API_KEY: 'sk-test' };
|
|
79
|
+
|
|
80
|
+
it('returns a setup card when no model is configured', async () => {
|
|
81
|
+
const server = buildServer(defineAgent({}, { env: {} }));
|
|
82
|
+
const body: any = await (await ask(server, { messages: [] })).json();
|
|
83
|
+
|
|
84
|
+
expect(body.type).toBe('setup');
|
|
85
|
+
expect(body.message).toContain('JANUX_MODEL');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('answers plain text and reports the resolved model', async () => {
|
|
89
|
+
const { fetchImpl } = scriptedFetch([anthropicReply([{ type: 'text', text: 'Hello!' }])]);
|
|
90
|
+
const server = buildServer(defineAgent({}, { env, fetchImpl }));
|
|
91
|
+
const body: any = await (await ask(server, { messages: [{ role: 'user', content: 'hi' }] })).json();
|
|
92
|
+
|
|
93
|
+
expect(body).toMatchObject({ type: 'text', text: 'Hello!', model: 'anthropic/claude-sonnet-5' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('executes api.* tools server-side and continues the loop', async () => {
|
|
97
|
+
const { fetchImpl, calls } = scriptedFetch([
|
|
98
|
+
anthropicReply([{ type: 'tool_use', id: 't1', name: 'api__shop__search', input: { q: 'shoes' } }]),
|
|
99
|
+
anthropicReply([{ type: 'text', text: 'Found 1 result' }]),
|
|
100
|
+
]);
|
|
101
|
+
const server = buildServer(defineAgent({}, { env, fetchImpl }));
|
|
102
|
+
const body: any = await (await ask(server, { messages: [{ role: 'user', content: 'search shoes' }] })).json();
|
|
103
|
+
|
|
104
|
+
expect(body.type).toBe('text');
|
|
105
|
+
expect(body.text).toBe('Found 1 result');
|
|
106
|
+
const toolResult = calls[1]!.body.messages.find((m: any) => m.content?.[0]?.type === 'tool_result');
|
|
107
|
+
|
|
108
|
+
expect(toolResult.content[0].content).toBe('["found:shoes"]');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns ui_calls for UI tools so the client bridge executes them', async () => {
|
|
112
|
+
const { fetchImpl } = scriptedFetch([
|
|
113
|
+
anthropicReply([{ type: 'tool_use', id: 't2', name: 'counter__rename', input: { label: 'x' } }]),
|
|
114
|
+
]);
|
|
115
|
+
const server = buildServer(defineAgent({}, { env, fetchImpl }));
|
|
116
|
+
const body: any = await (await ask(server, { messages: [{ role: 'user', content: 'rename' }] })).json();
|
|
117
|
+
|
|
118
|
+
expect(body.type).toBe('ui_calls');
|
|
119
|
+
expect(body.calls).toEqual([{ id: 't2', name: 'counter.rename', input: { label: 'x' } }]);
|
|
120
|
+
expect(body.messages.at(-1).toolCalls).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('coalesces parallel tool results into one user message (Anthropic alternation)', async () => {
|
|
124
|
+
const { fetchImpl, calls } = scriptedFetch([
|
|
125
|
+
anthropicReply([
|
|
126
|
+
{ type: 'tool_use', id: 'a1', name: 'api__shop__search', input: { q: 'x' } },
|
|
127
|
+
{ type: 'tool_use', id: 'a2', name: 'api__shop__search', input: { q: 'y' } },
|
|
128
|
+
]),
|
|
129
|
+
anthropicReply([{ type: 'text', text: 'both done' }]),
|
|
130
|
+
]);
|
|
131
|
+
const server = buildServer(defineAgent({}, { env, fetchImpl }));
|
|
132
|
+
const body: any = await (await ask(server, { messages: [{ role: 'user', content: 'go' }] })).json();
|
|
133
|
+
|
|
134
|
+
expect(body.text).toBe('both done');
|
|
135
|
+
const secondRequest = calls[1]!.body.messages;
|
|
136
|
+
const userMessages = secondRequest.filter((m: any) => m.role === 'user');
|
|
137
|
+
|
|
138
|
+
expect(userMessages).toHaveLength(2);
|
|
139
|
+
expect(userMessages[1].content).toHaveLength(2);
|
|
140
|
+
expect(userMessages[1].content.map((b: any) => b.tool_use_id)).toEqual(['a1', 'a2']);
|
|
141
|
+
secondRequest.forEach((message: any, index: number) => {
|
|
142
|
+
if (index === 0) return;
|
|
143
|
+
expect(message.role).not.toBe(secondRequest[index - 1].role);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('exposes manifest tools with guard annotations to the model', async () => {
|
|
148
|
+
const { fetchImpl, calls } = scriptedFetch([anthropicReply([{ type: 'text', text: 'ok' }])]);
|
|
149
|
+
const server = buildServer(defineAgent({}, { env, fetchImpl }));
|
|
150
|
+
|
|
151
|
+
await ask(server, { messages: [{ role: 'user', content: 'hi' }], path: '/' });
|
|
152
|
+
const toolNames = calls[0]!.body.tools.map((tool: any) => tool.name);
|
|
153
|
+
|
|
154
|
+
expect(toolNames).toContain('counter__rename');
|
|
155
|
+
expect(toolNames).toContain('api__shop__search');
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { AgentDeps, AgentMount } from '@janux/server';
|
|
2
|
+
import { resolveModel, setupCard, type ModelEnv } from './model';
|
|
3
|
+
import { callProvider, type AgentTool, type ChatMessage, type FetchLike, type ToolCall } from './providers';
|
|
4
|
+
|
|
5
|
+
export interface AgentConfig {
|
|
6
|
+
instructions?: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
maxTurns?: number;
|
|
9
|
+
tools?: { include?: string[] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AgentOverrides {
|
|
13
|
+
env?: ModelEnv;
|
|
14
|
+
fetchImpl?: FetchLike;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AgentRequestBody {
|
|
18
|
+
messages: ChatMessage[];
|
|
19
|
+
path?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SYSTEM_PREAMBLE = [
|
|
23
|
+
'You are the built-in copilot of a Janux application.',
|
|
24
|
+
'Tools prefixed "api." run on the server. All other tools operate the live UI;',
|
|
25
|
+
'tools marked [guard:confirm] return a proposal the human approves on the real UI.',
|
|
26
|
+
'Read resource state before acting. Never invent tool names.',
|
|
27
|
+
].join(' ');
|
|
28
|
+
|
|
29
|
+
function json(body: unknown, status = 200): Response {
|
|
30
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function includeTool(name: string, patterns: string[] | undefined): boolean {
|
|
34
|
+
if (!patterns || patterns.length === 0) return true;
|
|
35
|
+
|
|
36
|
+
return patterns.some((pattern) =>
|
|
37
|
+
pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function manifestTools(manifest: any, patterns: string[] | undefined): AgentTool[] {
|
|
42
|
+
return (manifest.tools ?? [])
|
|
43
|
+
.filter((tool: any) => includeTool(tool.name, patterns))
|
|
44
|
+
.map((tool: any) => ({
|
|
45
|
+
name: tool.name,
|
|
46
|
+
description: `${tool.description ?? ''} [guard:${tool.guard}]`.trim(),
|
|
47
|
+
input: tool.input,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function systemPrompt(config: AgentConfig, manifest: any): string {
|
|
52
|
+
const resources = JSON.stringify(manifest.resources ?? []);
|
|
53
|
+
|
|
54
|
+
return [config.instructions, SYSTEM_PREAMBLE, `Mounted resources: ${resources}`]
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join('\n\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runServerCalls(calls: ToolCall[], deps: AgentDeps): Promise<ChatMessage[]> {
|
|
60
|
+
const results: ChatMessage[] = [];
|
|
61
|
+
|
|
62
|
+
for (const call of calls) {
|
|
63
|
+
const content = await deps
|
|
64
|
+
.invoke(call.name, call.input)
|
|
65
|
+
.then((result) => JSON.stringify(result ?? null))
|
|
66
|
+
.catch((error) => JSON.stringify({ error: String(error) }));
|
|
67
|
+
|
|
68
|
+
results.push({ role: 'tool', toolCallId: call.id, content });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Zero-config embedded agent. Stateless HTTP turn protocol:
|
|
76
|
+
* - `{type:'text'}` final answer;
|
|
77
|
+
* - `{type:'ui_calls'}` the client executes via the gui-agent bridge and re-POSTs;
|
|
78
|
+
* - `{type:'setup'}` when no model/provider is configured.
|
|
79
|
+
*/
|
|
80
|
+
export function defineAgent(config: AgentConfig = {}, overrides: AgentOverrides = {}): AgentMount {
|
|
81
|
+
const env = overrides.env ?? (process.env as ModelEnv);
|
|
82
|
+
const fetchImpl = overrides.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
83
|
+
const maxTurns = config.maxTurns ?? 6;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
async handle(req: Request, deps: AgentDeps): Promise<Response> {
|
|
87
|
+
const model = resolveModel(config.model, env);
|
|
88
|
+
|
|
89
|
+
if (!model) return json(setupCard());
|
|
90
|
+
const body = (await req.json().catch(() => ({ messages: [] }))) as AgentRequestBody;
|
|
91
|
+
const manifest: any = await deps.manifestFor(body.path ?? '/');
|
|
92
|
+
const tools = manifestTools(manifest, config.tools?.include);
|
|
93
|
+
const system = systemPrompt(config, manifest);
|
|
94
|
+
const messages = [...(body.messages ?? [])];
|
|
95
|
+
|
|
96
|
+
for (let turn = 0; turn < maxTurns; turn += 1) {
|
|
97
|
+
const reply = await callProvider(model, system, messages, tools, fetchImpl);
|
|
98
|
+
|
|
99
|
+
messages.push({ role: 'assistant', content: reply.text, toolCalls: reply.toolCalls });
|
|
100
|
+
if (reply.toolCalls.length === 0) {
|
|
101
|
+
return json({ type: 'text', text: reply.text, messages, model: `${model.provider}/${model.model}` });
|
|
102
|
+
}
|
|
103
|
+
const serverCalls = reply.toolCalls.filter((call) => call.name.startsWith('api.'));
|
|
104
|
+
const uiCalls = reply.toolCalls.filter((call) => !call.name.startsWith('api.'));
|
|
105
|
+
|
|
106
|
+
messages.push(...(await runServerCalls(serverCalls, deps)));
|
|
107
|
+
if (uiCalls.length > 0) return json({ type: 'ui_calls', calls: uiCalls, messages });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return json({ type: 'text', text: 'I could not finish within the turn limit.', messages });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { defineAgent, type AgentConfig, type AgentOverrides } from './agent';
|
|
2
|
+
export { resolveModel, setupCard, type ResolvedModel, type ModelEnv } from './model';
|
|
3
|
+
export {
|
|
4
|
+
callProvider,
|
|
5
|
+
type AgentTool,
|
|
6
|
+
type ChatMessage,
|
|
7
|
+
type ToolCall,
|
|
8
|
+
type ProviderReply,
|
|
9
|
+
type FetchLike,
|
|
10
|
+
} from './providers';
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface ResolvedModel {
|
|
2
|
+
provider: 'anthropic' | 'openai' | 'google';
|
|
3
|
+
model: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ModelEnv {
|
|
9
|
+
[key: string]: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PROVIDER_KEYS: Record<ResolvedModel['provider'], string> = {
|
|
13
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
14
|
+
openai: 'OPENAI_API_KEY',
|
|
15
|
+
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MODELS: Record<ResolvedModel['provider'], string> = {
|
|
19
|
+
anthropic: 'claude-sonnet-5',
|
|
20
|
+
openai: 'gpt-5.2',
|
|
21
|
+
google: 'gemini-3-pro',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function fromIdentifier(identifier: string, env: ModelEnv, source: string): ResolvedModel | undefined {
|
|
25
|
+
const [provider, ...rest] = identifier.split('/') as [ResolvedModel['provider'], ...string[]];
|
|
26
|
+
const apiKey = env[PROVIDER_KEYS[provider] ?? ''];
|
|
27
|
+
|
|
28
|
+
if (!PROVIDER_KEYS[provider] || rest.length === 0) return undefined;
|
|
29
|
+
if (!apiKey) return undefined;
|
|
30
|
+
|
|
31
|
+
return { provider, model: rest.join('/'), apiKey, source };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sniffProvider(env: ModelEnv): ResolvedModel | undefined {
|
|
35
|
+
const available = (Object.keys(PROVIDER_KEYS) as ResolvedModel['provider'][]).filter(
|
|
36
|
+
(provider) => env[PROVIDER_KEYS[provider]],
|
|
37
|
+
);
|
|
38
|
+
const provider = available[0];
|
|
39
|
+
|
|
40
|
+
if (!provider) return undefined;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
provider,
|
|
44
|
+
model: DEFAULT_MODELS[provider],
|
|
45
|
+
apiKey: env[PROVIDER_KEYS[provider]]!,
|
|
46
|
+
source: `inferred from ${PROVIDER_KEYS[provider]}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* RFC §8.1 resolution order: explicit code → JANUX_MODEL env → provider key
|
|
52
|
+
* sniffing → undefined (the app still boots; the agent answers with a setup card).
|
|
53
|
+
*/
|
|
54
|
+
export function resolveModel(explicit: string | undefined, env: ModelEnv): ResolvedModel | undefined {
|
|
55
|
+
if (explicit) return fromIdentifier(explicit, env, 'defineAgent({ model })');
|
|
56
|
+
if (env.JANUX_MODEL) return fromIdentifier(env.JANUX_MODEL, env, 'JANUX_MODEL');
|
|
57
|
+
|
|
58
|
+
return sniffProvider(env);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setupCard(): Record<string, unknown> {
|
|
62
|
+
return {
|
|
63
|
+
type: 'setup',
|
|
64
|
+
message:
|
|
65
|
+
'No model configured. Set JANUX_MODEL="provider/model" or one provider API key ' +
|
|
66
|
+
`(${Object.values(PROVIDER_KEYS).join(', ')}).`,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ResolvedModel } from './model';
|
|
2
|
+
|
|
3
|
+
export interface AgentTool {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
input?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ChatMessage {
|
|
10
|
+
role: 'user' | 'assistant' | 'tool';
|
|
11
|
+
content: string;
|
|
12
|
+
toolCalls?: ToolCall[];
|
|
13
|
+
toolCallId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToolCall {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
input: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProviderReply {
|
|
23
|
+
text: string;
|
|
24
|
+
toolCalls: ToolCall[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
function anthropicMessages(messages: ChatMessage[]): unknown[] {
|
|
30
|
+
return messages.reduce<any[]>((acc, message) => {
|
|
31
|
+
if (message.role === 'tool') {
|
|
32
|
+
const block = { type: 'tool_result', tool_use_id: message.toolCallId, content: message.content };
|
|
33
|
+
const last = acc.at(-1);
|
|
34
|
+
const lastIsToolResults =
|
|
35
|
+
last?.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result';
|
|
36
|
+
|
|
37
|
+
lastIsToolResults ? last.content.push(block) : acc.push({ role: 'user', content: [block] });
|
|
38
|
+
|
|
39
|
+
return acc;
|
|
40
|
+
}
|
|
41
|
+
if (message.role === 'assistant' && message.toolCalls?.length) {
|
|
42
|
+
acc.push({
|
|
43
|
+
role: 'assistant',
|
|
44
|
+
content: [
|
|
45
|
+
...(message.content ? [{ type: 'text', text: message.content }] : []),
|
|
46
|
+
...message.toolCalls.map((call) => ({ type: 'tool_use', id: call.id, name: call.name, input: call.input })),
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return acc;
|
|
51
|
+
}
|
|
52
|
+
acc.push({ role: message.role, content: message.content });
|
|
53
|
+
|
|
54
|
+
return acc;
|
|
55
|
+
}, []);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function callAnthropic(
|
|
59
|
+
model: ResolvedModel,
|
|
60
|
+
system: string,
|
|
61
|
+
messages: ChatMessage[],
|
|
62
|
+
tools: AgentTool[],
|
|
63
|
+
fetchImpl: FetchLike,
|
|
64
|
+
): Promise<ProviderReply> {
|
|
65
|
+
const response = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'content-type': 'application/json',
|
|
69
|
+
'x-api-key': model.apiKey,
|
|
70
|
+
'anthropic-version': '2023-06-01',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model: model.model,
|
|
74
|
+
max_tokens: 4096,
|
|
75
|
+
system,
|
|
76
|
+
messages: anthropicMessages(messages),
|
|
77
|
+
tools: tools.map((tool) => ({
|
|
78
|
+
name: tool.name.replace(/\./g, '__'),
|
|
79
|
+
description: tool.description ?? '',
|
|
80
|
+
input_schema: tool.input ?? { type: 'object', properties: {} },
|
|
81
|
+
})),
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) throw new Error(`Anthropic API error ${response.status}: ${await response.text()}`);
|
|
86
|
+
const body: any = await response.json();
|
|
87
|
+
const blocks: any[] = body.content ?? [];
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
text: blocks.filter((block) => block.type === 'text').map((block) => block.text).join(''),
|
|
91
|
+
toolCalls: blocks
|
|
92
|
+
.filter((block) => block.type === 'tool_use')
|
|
93
|
+
.map((block) => ({ id: block.id, name: block.name.replace(/__/g, '.'), input: block.input })),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const OPENAI_COMPAT_URLS: Record<string, string> = {
|
|
98
|
+
openai: 'https://api.openai.com/v1/chat/completions',
|
|
99
|
+
google: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
async function callOpenAi(
|
|
103
|
+
model: ResolvedModel,
|
|
104
|
+
system: string,
|
|
105
|
+
messages: ChatMessage[],
|
|
106
|
+
tools: AgentTool[],
|
|
107
|
+
fetchImpl: FetchLike,
|
|
108
|
+
): Promise<ProviderReply> {
|
|
109
|
+
const response = await fetchImpl(OPENAI_COMPAT_URLS[model.provider]!, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${model.apiKey}` },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
model: model.model,
|
|
114
|
+
messages: [
|
|
115
|
+
{ role: 'system', content: system },
|
|
116
|
+
...messages.map((message) => openAiMessage(message)),
|
|
117
|
+
],
|
|
118
|
+
tools: tools.map((tool) => ({
|
|
119
|
+
type: 'function',
|
|
120
|
+
function: { name: tool.name.replace(/\./g, '__'), description: tool.description ?? '', parameters: tool.input ?? {} },
|
|
121
|
+
})),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!response.ok) throw new Error(`OpenAI API error ${response.status}: ${await response.text()}`);
|
|
126
|
+
const body: any = await response.json();
|
|
127
|
+
const choice = body.choices?.[0]?.message ?? {};
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
text: choice.content ?? '',
|
|
131
|
+
toolCalls: (choice.tool_calls ?? []).map((call: any) => ({
|
|
132
|
+
id: call.id,
|
|
133
|
+
name: call.function.name.replace(/__/g, '.'),
|
|
134
|
+
input: JSON.parse(call.function.arguments || '{}'),
|
|
135
|
+
})),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function openAiMessage(message: ChatMessage): unknown {
|
|
140
|
+
if (message.role === 'tool') {
|
|
141
|
+
return { role: 'tool', tool_call_id: message.toolCallId, content: message.content };
|
|
142
|
+
}
|
|
143
|
+
if (message.role === 'assistant' && message.toolCalls?.length) {
|
|
144
|
+
return {
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
content: message.content || null,
|
|
147
|
+
tool_calls: message.toolCalls.map((call) => ({
|
|
148
|
+
id: call.id,
|
|
149
|
+
type: 'function',
|
|
150
|
+
function: { name: call.name.replace(/\./g, '__'), arguments: JSON.stringify(call.input ?? {}) },
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { role: message.role, content: message.content };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Routes a chat turn to the resolved provider. Google reuses the OpenAI-compatible endpoint. */
|
|
159
|
+
export async function callProvider(
|
|
160
|
+
model: ResolvedModel,
|
|
161
|
+
system: string,
|
|
162
|
+
messages: ChatMessage[],
|
|
163
|
+
tools: AgentTool[],
|
|
164
|
+
fetchImpl: FetchLike,
|
|
165
|
+
): Promise<ProviderReply> {
|
|
166
|
+
if (model.provider === 'anthropic') return callAnthropic(model, system, messages, tools, fetchImpl);
|
|
167
|
+
|
|
168
|
+
return callOpenAi(model, system, messages, tools, fetchImpl);
|
|
169
|
+
}
|