Skip to content

Commit 97bacfa

Browse files
authored
Merge pull request #2 from tomcant/openai-provider-support
feat(llm): add OpenAI adapter with dynamic provider selection
2 parents 8433b38 + 025706d commit 97bacfa

12 files changed

Lines changed: 549 additions & 44 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ This is the repository for Agent Toolsmith, a general-purpose AI agent that writ
2424

2525
Three layers — the **agent core**, a **pluggable LLM adapter**, and the **terminal UI** — held apart by two small interfaces. The agent drives the conversation without knowing which model backs it or how its output is shown: it reaches the model through `LlmClient` and emits a stream of `AgentEvent`s the UI renders. Everything else plugs into one of those two seams.
2626

27-
**`LlmClient`** (`agent/types.ts`) — a single `send(messages, tools, signal)` method returning an async stream of `text_delta`, `tool_call`, and a final `complete` event. To add a provider, write an adapter and list it in `adapters/llm/index.ts`; `resolveLlmClient` picks the first whose environment keys are present.
27+
**`LlmClient`** (`agent/types.ts`) — a single `send(messages, tools, signal)` method returning an async stream of `text_delta`, `tool_call`, and a final `complete` event. To add a provider, write an `LlmAdapter` (`adapters/llm/types.ts`) and list it in `adapters/llm/index.ts`; `resolveLlmClientFromEnv` picks the first whose environment keys are present, and `resolveLlmClientFromApiKey` picks the one whose `matchesApiKey` recognises a raw key given as input.
2828

2929
**`Tool`** (`agent/tools/types.ts`) — `{ name, description, inputSchema, execute }`. The registry holds tools in memory; the store persists evolved ones to disk as TypeScript and reloads them on launch. `evolve` is itself just a built-in tool that writes to the registry — the same seam every evolved tool flows through.
3030

@@ -49,10 +49,12 @@ src/
4949
5050
├── adapters/
5151
│ └── llm/
52-
│ ├── index.ts resolveLlmClient() — selects a provider from env keys
53-
│ └── anthropic.ts Anthropic implementation of LlmClient
52+
│ ├── index.ts Provider selection — from env keys, or from a raw key given as input
53+
│ ├── types.ts LlmAdapter — how a provider is discovered and constructed
54+
│ ├── anthropic.ts Anthropic implementation of LlmClient
55+
│ └── openai.ts OpenAI (Responses API) implementation of LlmClient
5456
55-
├── tui/ Ink/React terminal UI — App, components, slash commands, transcript
57+
├── tui/ OpenTUI/React terminal UI — App, components, slash commands, transcript
5658
└── demo/ Fake LlmClient, sample tools, and scripted scenarios (env DEMO=1)
5759
```
5860

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Calling `evolve` again with an existing name replaces that tool, so the model ca
5555
## Prerequisites
5656

5757
- [Bun](https://bun.sh) JavaScript runtime
58-
- [Anthropic](https://platform.claude.com) API key
58+
- An [Anthropic](https://platform.claude.com) or [OpenAI](https://platform.openai.com) API key
5959

6060
## Setup
6161

@@ -71,12 +71,17 @@ Start an interactive chat:
7171
bun run src/index.ts
7272
```
7373

74-
The agent prompts for your Anthropic API key on launch if it isn't already set. To skip the prompt, set it in your environment beforehand:
74+
The agent prompts for an API key on launch if no provider is configured. It reads the key's format to
75+
tell the providers apart, so either an Anthropic or an OpenAI key works. To skip the prompt, set one in
76+
your environment beforehand:
7577

7678
```sh
77-
export ANTHROPIC_API_KEY="sk-ant-..."
79+
export ANTHROPIC_API_KEY="sk-ant-..." # claude-sonnet-4-6 by default
80+
export OPENAI_API_KEY="sk-..." # gpt-5-mini by default
7881
```
7982

83+
Anthropic is preferred when both are set. `MODEL` overrides the default model for whichever provider resolves.
84+
8085
### Standalone binary
8186

8287
Compile a self-contained executable (bundles the Bun runtime and all dependencies):

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@anthropic-ai/sdk": "^0.96.0",
1717
"@opentui/core": "0.5.3",
1818
"@opentui/react": "0.5.3",
19+
"openai": "^7.5.0",
1920
"opentui-spinner": "^0.0.7",
2021
"react": "^19.2.7",
2122
"web-tree-sitter": "0.25.10"

src/adapters/llm/anthropic.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
import Anthropic from "@anthropic-ai/sdk";
22
import type { ToolInput, ToolMetadata } from "#/agent/tools/types.ts";
33
import type { LlmClient, LlmEvent, Message, MessagePart } from "#/agent/types.ts";
4+
import type { LlmAdapter } from "./types.ts";
45

5-
export function anthropicFromEnv(
6-
env: Record<string, string | undefined>,
7-
systemPrompt?: string,
8-
): AnthropicLlmClient | null {
9-
const apiKey = env.ANTHROPIC_API_KEY;
10-
if (!apiKey) return null;
6+
export const anthropicAdapter: LlmAdapter = {
7+
matchesApiKey: (apiKey) => apiKey.startsWith("sk-ant-"),
118

12-
// A custom base URL means a proxy or gateway, whose keys use their own format.
13-
if (!env.ANTHROPIC_BASE_URL && !apiKey.startsWith("sk-ant-")) {
14-
return null;
15-
}
9+
fromApiKey(apiKey, systemPrompt, model) {
10+
return new AnthropicLlmClient(
11+
new Anthropic({ apiKey }),
12+
model ?? "claude-sonnet-4-6",
13+
systemPrompt,
14+
);
15+
},
1616

17-
return new AnthropicLlmClient(
18-
new Anthropic({ apiKey }),
19-
env.MODEL ?? "claude-sonnet-4-6",
20-
systemPrompt,
21-
);
22-
}
17+
tryFromEnv(env, systemPrompt) {
18+
const apiKey = env.ANTHROPIC_API_KEY;
19+
if (!apiKey) return null;
20+
21+
// A custom base URL means a proxy or gateway, whose keys use their own format.
22+
if (!env.ANTHROPIC_BASE_URL && !this.matchesApiKey(apiKey)) {
23+
return null;
24+
}
25+
26+
return this.fromApiKey(apiKey, systemPrompt, env.MODEL);
27+
},
28+
};
2329

2430
export class AnthropicLlmClient implements LlmClient {
2531
readonly provider = "anthropic";

src/adapters/llm/index.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
import type { LlmClient } from "#/agent/types.ts";
2-
import { anthropicFromEnv } from "./anthropic.ts";
2+
import { anthropicAdapter } from "./anthropic.ts";
3+
import { openaiAdapter } from "./openai.ts";
4+
import type { Env, LlmAdapter } from "./types.ts";
35

4-
type Env = Record<string, string | undefined>;
5-
type LlmFromEnv = (env: Env, systemPrompt?: string) => LlmClient | null;
6-
7-
const adapters: LlmFromEnv[] = [anthropicFromEnv];
6+
const adapters: LlmAdapter[] = [anthropicAdapter, openaiAdapter];
87

98
// Returns the first provider whose keys are present in the environment.
10-
export function resolveLlmClient(systemPrompt?: string, env: Env = process.env): LlmClient | null {
11-
for (const fromEnv of adapters) {
12-
const client = fromEnv(env, systemPrompt);
9+
export function resolveLlmClientFromEnv(
10+
systemPrompt?: string,
11+
env: Env = process.env,
12+
): LlmClient | null {
13+
for (const adapter of adapters) {
14+
const client = adapter.tryFromEnv(env, systemPrompt);
1315
if (client) return client;
1416
}
1517
return null;
1618
}
19+
20+
export function resolveLlmClientFromApiKey(
21+
apiKey: string,
22+
systemPrompt?: string,
23+
env: Env = process.env,
24+
): LlmClient | null {
25+
const adapter = adapters.find((candidate) => candidate.matchesApiKey(apiKey));
26+
return adapter?.fromApiKey(apiKey, systemPrompt, env.MODEL) ?? null;
27+
}

src/adapters/llm/openai.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import OpenAI from "openai";
2+
import type { ToolInput, ToolMetadata } from "#/agent/tools/types.ts";
3+
import type { LlmClient, LlmEvent, Message, MessagePart } from "#/agent/types.ts";
4+
import type { LlmAdapter } from "./types.ts";
5+
6+
export const openaiAdapter: LlmAdapter = {
7+
matchesApiKey: (apiKey) => apiKey.startsWith("sk-") && !apiKey.startsWith("sk-ant-"),
8+
9+
fromApiKey(apiKey, systemPrompt, model) {
10+
return new OpenAiLlmClient(new OpenAI({ apiKey }), model ?? "gpt-5-mini", systemPrompt);
11+
},
12+
13+
tryFromEnv(env, systemPrompt) {
14+
const apiKey = env.OPENAI_API_KEY;
15+
if (!apiKey) return null;
16+
17+
// A custom base URL means a proxy or gateway, whose keys use their own format.
18+
if (!env.OPENAI_BASE_URL && !this.matchesApiKey(apiKey)) {
19+
return null;
20+
}
21+
22+
return this.fromApiKey(apiKey, systemPrompt, env.MODEL);
23+
},
24+
};
25+
26+
export class OpenAiLlmClient implements LlmClient {
27+
readonly provider = "openai";
28+
29+
constructor(
30+
private readonly sdk: OpenAI,
31+
readonly model: string,
32+
private readonly systemPrompt?: string,
33+
) {}
34+
35+
async *send(
36+
messages: Message[],
37+
tools?: ToolMetadata[],
38+
signal?: AbortSignal,
39+
): AsyncGenerator<LlmEvent> {
40+
const stream = await this.sdk.responses.create(
41+
{
42+
model: this.model,
43+
stream: true,
44+
input: messages.flatMap(toSdkInputItems),
45+
...(tools && tools.length > 0 ? { tools: tools.map(toSdkTool) } : {}),
46+
...(this.systemPrompt ? { instructions: this.systemPrompt } : {}),
47+
},
48+
{ signal },
49+
);
50+
51+
const toolCalls = new Map<number, { callId: string; name: string; args: string }>();
52+
let response: MessagePart[] | undefined;
53+
54+
for await (const event of stream) {
55+
switch (event.type) {
56+
case "response.output_text.delta":
57+
yield {
58+
type: "text_delta",
59+
text: event.delta,
60+
};
61+
break;
62+
63+
case "response.output_item.added":
64+
if (event.item.type === "function_call") {
65+
toolCalls.set(event.output_index, {
66+
callId: event.item.call_id,
67+
name: event.item.name,
68+
args: "",
69+
});
70+
}
71+
break;
72+
73+
case "response.function_call_arguments.delta": {
74+
const call = toolCalls.get(event.output_index);
75+
if (call) call.args += event.delta;
76+
break;
77+
}
78+
79+
case "response.output_item.done": {
80+
const call = toolCalls.get(event.output_index);
81+
if (call) {
82+
const args = event.item.type === "function_call" ? event.item.arguments : call.args;
83+
yield {
84+
type: "tool_call",
85+
id: call.callId,
86+
name: call.name,
87+
input: args ? JSON.parse(args) : {},
88+
};
89+
toolCalls.delete(event.output_index);
90+
}
91+
break;
92+
}
93+
94+
case "response.completed":
95+
response = fromSdkOutput(event.response.output);
96+
break;
97+
98+
case "response.failed":
99+
throw new Error(event.response.error?.message ?? "OpenAI response failed");
100+
101+
case "response.incomplete":
102+
throw new Error(
103+
event.response.incomplete_details?.reason ?? "OpenAI response was incomplete",
104+
);
105+
}
106+
}
107+
108+
if (response) {
109+
yield {
110+
type: "complete",
111+
response,
112+
};
113+
}
114+
}
115+
}
116+
117+
function toSdkInputItems(message: Message): OpenAI.Responses.ResponseInputItem[] {
118+
return message.content.map((part): OpenAI.Responses.ResponseInputItem => {
119+
if (part.type === "text") {
120+
return {
121+
role: message.role,
122+
content: part.text,
123+
};
124+
}
125+
if (part.type === "tool_call") {
126+
return {
127+
type: "function_call",
128+
call_id: part.id,
129+
name: part.name,
130+
arguments: JSON.stringify(part.input),
131+
};
132+
}
133+
return {
134+
type: "function_call_output",
135+
call_id: part.toolCallId,
136+
output: part.content,
137+
};
138+
});
139+
}
140+
141+
function toSdkTool(tool: ToolMetadata): OpenAI.Responses.FunctionTool {
142+
return {
143+
type: "function",
144+
name: tool.name,
145+
description: tool.description,
146+
parameters: tool.inputSchema,
147+
strict: false,
148+
};
149+
}
150+
151+
function fromSdkOutput(output: OpenAI.Responses.ResponseOutputItem[]): MessagePart[] {
152+
return output.flatMap((item): MessagePart[] => {
153+
if (item.type === "message") {
154+
return item.content.flatMap((part): MessagePart[] =>
155+
part.type === "output_text" ? [{ type: "text", text: part.text }] : [],
156+
);
157+
}
158+
if (item.type === "function_call") {
159+
return [
160+
{
161+
type: "tool_call",
162+
id: item.call_id,
163+
name: item.name,
164+
input: (item.arguments ? JSON.parse(item.arguments) : {}) as ToolInput,
165+
},
166+
];
167+
}
168+
return [];
169+
});
170+
}

src/adapters/llm/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { LlmClient } from "#/agent/types.ts";
2+
3+
export type Env = Record<string, string | undefined>;
4+
5+
export type LlmAdapter = {
6+
matchesApiKey(apiKey: string): boolean;
7+
fromApiKey(apiKey: string, systemPrompt?: string, model?: string): LlmClient;
8+
tryFromEnv(env: Env, systemPrompt?: string): LlmClient | null;
9+
};

src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createCliRenderer } from "@opentui/core";
22
import { createRoot } from "@opentui/react";
33
import { createElement } from "react";
4-
import { resolveLlmClient } from "./adapters/llm";
4+
import { resolveLlmClientFromApiKey, resolveLlmClientFromEnv } from "./adapters/llm";
55
import { createAgent } from "./agent";
66
import { createDemoAgent } from "./demo";
77
import systemPrompt from "./prompt.md";
@@ -10,7 +10,7 @@ import { App } from "./tui/App.tsx";
1010
const agent =
1111
process.env.DEMO === "1"
1212
? await createDemoAgent()
13-
: await createAgent(resolveLlmClient(systemPrompt));
13+
: await createAgent(resolveLlmClientFromEnv(systemPrompt));
1414

1515
const renderer = await createCliRenderer();
1616

@@ -19,7 +19,7 @@ const themeMode = (await renderer.waitForThemeMode(200)) ?? "dark";
1919
createRoot(renderer).render(createElement(App, { agent, attachApiKey, themeMode }));
2020

2121
function attachApiKey(apiKey: string) {
22-
const client = resolveLlmClient(systemPrompt, { ...process.env, ANTHROPIC_API_KEY: apiKey });
22+
const client = resolveLlmClientFromApiKey(apiKey, systemPrompt);
2323
if (!client) return null;
2424
agent.setClient(client);
2525
return agent.modelInfo();

src/tui/components/ApiKeyPrompt.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export function ApiKeyPrompt({ error, onSubmit }: ApiKeyPromptProps) {
1313
<box style={{ gap: 1 }}>
1414
<box>
1515
<text fg={theme.muted}>
16-
No LLM provider is configured. Paste an <span fg={theme.accent}>Anthropic API key</span>{" "}
17-
to continue, or press Ctrl+C to quit.
16+
No LLM provider is configured. Paste an <span fg={theme.accent}>Anthropic</span> or{" "}
17+
<span fg={theme.accent}>OpenAI</span> key to continue.
1818
</text>
1919
</box>
2020
<box
@@ -29,7 +29,7 @@ export function ApiKeyPrompt({ error, onSubmit }: ApiKeyPromptProps) {
2929
<input
3030
focused
3131
attributes={TextAttributes.HIDDEN}
32-
placeholder="sk-ant-..."
32+
placeholder="sk-ant-... or sk-..."
3333
placeholderColor={theme.muted}
3434
cursorColor={theme.foreground}
3535
onSubmit={(input) => onSubmit(input as string)}

0 commit comments

Comments
 (0)