Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ func main() {
}
```

Swap to xAI Grok by changing one import:
Swap to OpenAI by changing one import:

```go
import "github.com/katasec/forge/provider/openai"

provider := openai.New("https://api.x.ai/v1", os.Getenv("XAI_API_KEY"), "grok-3-mini")
provider := openai.New(os.Getenv("OPENAI_API_KEY"), openai.ModelGPT54Nano)
```

The `openai` package works with any OpenAI-compatible API (xAI, OpenAI, Together, Groq, etc.).
The `openai` package uses the OpenAI Responses API, including text and image content.

Or use the xAI Responses API with built-in web search:

Expand Down Expand Up @@ -135,6 +135,16 @@ resp, err := agent.Ask(ctx, "Hello")
fmt.Println(resp.LastText())
```

For multimodal input, use `AskContent`:

```go
resp, err := agent.AskContent(ctx,
forge.Text("Describe this image."),
forge.ImageURL("https://example.com/cat.png"),
)
fmt.Println(resp.LastText())
```

Use `AskIn` when you want to manage multiple named conversations:

```go
Expand Down
19 changes: 8 additions & 11 deletions _examples/calculator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,16 @@ func (p *MockProvider) Generate(_ context.Context, req forge.ProviderRequest) (*
// First call: "LLM" decides to use the add tool.
if p.calls == 1 {
return &forge.ProviderResponse{
Message: forge.Message{
Messages: []forge.Message{{
Role: forge.RoleAssistant,
ToolCalls: []forge.ToolCall{
{
Content: []forge.ContentBlock{
forge.ToolCallBlock(forge.ToolCall{
ID: "call-1",
Name: "add",
Arguments: json.RawMessage(`{"a": 12, "b": 30}`),
},
}),
},
},
}},
FinishReason: forge.FinishReasonToolUse,
Usage: forge.TokenUsage{InputTokens: 25, OutputTokens: 15},
}, nil
Expand All @@ -62,16 +62,13 @@ func (p *MockProvider) Generate(_ context.Context, req forge.ProviderRequest) (*
// Look at the last message to find the tool result.
var toolResult string
for _, msg := range req.Messages {
if msg.Role == forge.RoleTool && len(msg.ToolResults) > 0 {
toolResult = msg.ToolResults[0].Content
if msg.Role == forge.RoleTool && len(msg.ToolResults()) > 0 {
toolResult = msg.ToolResults()[0].Content
}
}

return &forge.ProviderResponse{
Message: forge.Message{
Role: forge.RoleAssistant,
Content: fmt.Sprintf("The answer is %s!", toolResult),
},
Messages: []forge.Message{forge.AssistantText(fmt.Sprintf("The answer is %s!", toolResult))},
FinishReason: forge.FinishReasonStop,
Usage: forge.TokenUsage{InputTokens: 40, OutputTokens: 10},
}, nil
Expand Down
9 changes: 8 additions & 1 deletion _examples/chat-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ export ANTHROPIC_API_KEY=sk-ant-...
go run .
```

Use xAI's OpenAI-compatible endpoint:
Use OpenAI's Responses API:

```bash
export OPENAI_API_KEY=sk-...
go run . -provider openai
```

Use xAI's Responses API:

```bash
export XAI_API_KEY=xai-...
Expand Down
7 changes: 5 additions & 2 deletions _examples/chat-console/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ func buildProvider(name string) (forge.Provider, func()) {
return anthropic.New(key, "claude-sonnet-4-20250514"), func() {}
case "xai":
key := requireEnv("XAI_API_KEY")
return openai.New("https://api.x.ai/v1", key, "grok-3-mini"), func() {}
return xai.New(key, xai.ModelGrok4FastNonReasoning), func() {}
case "openai":
key := requireEnv("OPENAI_API_KEY")
return openai.New(key, openai.ModelGPT54Nano), func() {}
case "xai-search":
key := requireEnv("XAI_API_KEY")
provider := xai.New(key, xai.ModelGrok4FastNonReasoning, xai.WithWebSearch())
return provider, func() { printCitations(provider.LastCitations()) }
default:
log.Fatalf("unknown provider %q; use anthropic, xai, or xai-search", name)
log.Fatalf("unknown provider %q; use anthropic, openai, xai, or xai-search", name)
return nil, nil
}
}
Expand Down
21 changes: 14 additions & 7 deletions _examples/hello-world/main.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// Hello World is the simplest possible forge example.
//
// Shows how to call Claude with your Anthropic API key, and how to
// swap to xAI's Grok by changing one line.
// swap to xAI's Grok or OpenAI by changing one flag.
//
// Usage:
//
// export ANTHROPIC_API_KEY=sk-ant-...
// go run .
//
// # Or use xAI's OpenAI-compatible endpoint instead:
// export XAI_API_KEY=xai-...
// go run . -provider xai
// # Or use OpenAI's Responses API instead:
// export OPENAI_API_KEY=sk-...
// go run . -provider openai
//
// # Or use xAI Responses API with web search:
// export XAI_API_KEY=xai-...
Expand All @@ -31,7 +31,7 @@ import (
)

func main() {
providerFlag := flag.String("provider", "anthropic", "Provider to use: anthropic, xai, or xai-search")
providerFlag := flag.String("provider", "anthropic", "Provider to use: anthropic, openai, xai, or xai-search")
flag.Parse()

// Pick your provider. The agent setup below stays the same.
Expand All @@ -44,12 +44,19 @@ func main() {
log.Fatal("Set ANTHROPIC_API_KEY environment variable")
}
provider = anthropic.New(key, "claude-sonnet-4-20250514")
case "openai":
key := os.Getenv("OPENAI_API_KEY")
if key == "" {
log.Fatal("Set OPENAI_API_KEY environment variable")
}
provider = openai.New(key, openai.ModelGPT54Nano)
case "xai":
key := os.Getenv("XAI_API_KEY")
if key == "" {
log.Fatal("Set XAI_API_KEY environment variable")
}
provider = openai.New("https://api.x.ai/v1", key, "grok-3-mini")
xaiProvider = xai.New(key, xai.ModelGrok4FastNonReasoning)
provider = xaiProvider
case "xai-search":
key := os.Getenv("XAI_API_KEY")
if key == "" {
Expand All @@ -58,7 +65,7 @@ func main() {
xaiProvider = xai.New(key, xai.ModelGrok4FastNonReasoning, xai.WithWebSearch())
provider = xaiProvider
default:
log.Fatalf("Unknown provider: %s (use 'anthropic', 'xai', or 'xai-search')", *providerFlag)
log.Fatalf("Unknown provider: %s (use 'anthropic', 'openai', 'xai', or 'xai-search')", *providerFlag)
}

// Build the agent: provider, prompt, and runtime behavior live in Config.
Expand Down
39 changes: 29 additions & 10 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"

"github.com/google/uuid"
"github.com/katasec/forge/message"
)

// AgentRequest is the input to Agent.Run.
Expand All @@ -26,8 +27,8 @@ type AgentResponse struct {
func (r *AgentResponse) LastText() string {
for i := len(r.Messages) - 1; i >= 0; i-- {
msg := r.Messages[i]
if msg.Role == RoleAssistant && msg.Content != "" {
return msg.Content
if msg.Role == RoleAssistant && msg.Text() != "" {
return msg.Text()
}
}
return ""
Expand Down Expand Up @@ -97,7 +98,15 @@ func (a *Agent) Ask(ctx context.Context, prompt string) (*AgentResponse, error)
func (a *Agent) AskIn(ctx context.Context, conversationID, prompt string) (*AgentResponse, error) {
return a.Run(ctx, AgentRequest{
ConversationID: conversationID,
Messages: []Message{UserMessage(prompt)},
Messages: []Message{UserText(prompt)},
})
}

// AskContent sends a rich user message in the agent's default conversation.
func (a *Agent) AskContent(ctx context.Context, blocks ...ContentBlock) (*AgentResponse, error) {
return a.Run(ctx, AgentRequest{
ConversationID: a.defaultConversationID,
Messages: []Message{UserMessage(blocks...)},
})
}

Expand Down Expand Up @@ -147,8 +156,11 @@ func (a *Agent) Run(ctx context.Context, req AgentRequest) (*AgentResponse, erro
}

usage.InputTokens += resp.Usage.InputTokens
usage.CachedInputTokens += resp.Usage.CachedInputTokens
usage.OutputTokens += resp.Usage.OutputTokens
messages = append(messages, resp.Message)
usage.ReasoningOutputTokens += resp.Usage.ReasoningOutputTokens
usage.TotalTokens += resp.Usage.TotalTokens
messages = append(messages, resp.Messages...)
iteration++

if resp.FinishReason == FinishReasonStop {
Expand All @@ -157,7 +169,18 @@ func (a *Agent) Run(ctx context.Context, req AgentRequest) (*AgentResponse, erro
}

// FinishReason is tool_use - execute the tool calls.
toolResults := a.executor.Execute(ctx, resp.Message.ToolCalls)
if len(resp.Messages) == 0 {
finishReason = FinishReasonError
toolErrors = append(toolErrors, ToolError{Message: "provider requested tool use without a message"})
break
}
toolCalls := resp.Messages[len(resp.Messages)-1].ToolCalls()
if len(toolCalls) == 0 {
finishReason = FinishReasonError
toolErrors = append(toolErrors, ToolError{Message: "provider requested tool use without tool calls"})
break
}
toolResults := a.executor.Execute(ctx, toolCalls)

// Check for tool errors.
hasError := false
Expand All @@ -176,11 +199,7 @@ func (a *Agent) Run(ctx context.Context, req AgentRequest) (*AgentResponse, erro
}

// Append tool results message (even on error, for coherent history).
toolMsg := Message{
Role: RoleTool,
ToolResults: toolResults,
}
messages = append(messages, toolMsg)
messages = append(messages, message.ToolMessage(toolResults...))

if hasError {
break
Expand Down
Loading
Loading