Skip to content

Commit 2da95ab

Browse files
authored
Merge pull request #20 from initializ/core/openai-ent
feat: OpenAI Enterprise org ID support and TUI scroll fix
2 parents 1df1f42 + a735872 commit 2da95ab

34 files changed

Lines changed: 828 additions & 73 deletions

docs/commands.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ forge init [name] [flags]
3434
| `--tools` | | | Builtin tools to enable (e.g., `web_search,http_request`) |
3535
| `--skills` | | | Registry skills to include (e.g., `github,weather`) |
3636
| `--api-key` | | | LLM provider API key |
37+
| `--org-id` | | | OpenAI Organization ID (enterprise) |
3738
| `--from-skills` | | | Path to a SKILL.md file for auto-configuration |
3839
| `--non-interactive` | | `false` | Skip interactive prompts |
3940

@@ -62,6 +63,13 @@ forge init my-agent \
6263
--skills github \
6364
--api-key sk-... \
6465
--non-interactive
66+
67+
# OpenAI enterprise with organization ID
68+
forge init my-agent \
69+
--model-provider openai \
70+
--api-key sk-... \
71+
--org-id org-xxxxxxxxxxxxxxxxxxxxxxxx \
72+
--non-interactive
6573
```
6674

6775
---

docs/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ entrypoint: "agent.py" # Required for crewai/langchain, omit for fo
1616
model:
1717
provider: "openai" # openai, anthropic, gemini, ollama, custom
1818
name: "gpt-4o" # Model name
19+
organization_id: "org-xxx" # OpenAI Organization ID (enterprise, optional)
1920
fallbacks: # Fallback providers (optional)
2021
- provider: "anthropic"
2122
name: "claude-sonnet-4-20250514"
23+
organization_id: "" # Per-fallback org ID override (optional)
2224

2325
tools:
2426
- name: "web_search"
@@ -80,6 +82,7 @@ schedules: # Recurring scheduled tasks (optional)
8082
| `FORGE_MEMORY_LONG_TERM` | Set `true` to enable long-term memory |
8183
| `FORGE_EMBEDDING_PROVIDER` | Override embedding provider |
8284
| `OPENAI_API_KEY` | OpenAI API key |
85+
| `OPENAI_ORG_ID` | OpenAI Organization ID (enterprise); overrides `organization_id` in YAML |
8386
| `ANTHROPIC_API_KEY` | Anthropic API key |
8487
| `GEMINI_API_KEY` | Google Gemini API key |
8588
| `TAVILY_API_KEY` | Tavily web search API key |

docs/dashboard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ A multi-step wizard (web equivalent of `forge init`) that walks through the full
5050
|------|-------------|
5151
| Name | Set agent name with live slug preview |
5252
| Provider | Select LLM provider (OpenAI, Anthropic, Gemini, Ollama, Custom) with descriptions |
53-
| Model & Auth | Pick from provider-specific model lists; OpenAI supports API key or browser OAuth login |
53+
| Model & Auth | Pick from provider-specific model lists; OpenAI supports API key or browser OAuth login, plus optional Organization ID for enterprise accounts |
5454
| Channels | Select Slack/Telegram with inline token collection |
5555
| Tools | Select builtin tools; web_search shows Tavily vs Perplexity provider choice with API key input |
5656
| Skills | Browse registry skills by category with inline required/optional env var collection |

docs/hooks.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ hooks.Register(engine.BeforeToolExec, func(ctx context.Context, hctx *engine.Hoo
7373
})
7474
```
7575

76+
## Audit Logging
77+
78+
The runner registers `AfterLLMCall` hooks that emit structured audit events for each LLM interaction. Audit fields include:
79+
80+
| Field | Description |
81+
|-------|-------------|
82+
| `provider` | LLM provider name |
83+
| `model` | Model identifier |
84+
| `input_tokens` | Prompt token count |
85+
| `output_tokens` | Completion token count |
86+
| `organization_id` | OpenAI Organization ID (when set) |
87+
88+
These events are logged via `slog` at Info level and can be consumed by external log aggregators for cost tracking and compliance.
89+
7690
## Progress Tracking
7791

7892
The runner automatically registers progress hooks that emit real-time status updates during tool execution. Progress events include the tool name, phase (`tool_start` / `tool_end`), and a human-readable status message. These events are streamed to clients via SSE when using the A2A HTTP server, enabling live progress indicators in web and chat UIs.

docs/runtime.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Forge supports multiple LLM providers with automatic fallback:
2727

2828
| Provider | Default Model | Auth |
2929
|----------|--------------|------|
30-
| `openai` | `gpt-5.2-2025-12-11` | API key or OAuth |
30+
| `openai` | `gpt-5.2-2025-12-11` | API key or OAuth; optional Organization ID |
3131
| `anthropic` | `claude-sonnet-4-20250514` | API key |
3232
| `gemini` | `gemini-2.5-flash` | API key |
3333
| `ollama` | `llama3` | None (local) |
@@ -67,6 +67,25 @@ forge init my-agent
6767

6868
OAuth tokens are stored in `~/.forge/credentials/openai.json` and automatically refreshed.
6969

70+
### Organization ID (OpenAI Enterprise)
71+
72+
Enterprise OpenAI accounts can set an Organization ID to route API requests to the correct org:
73+
74+
```yaml
75+
model:
76+
provider: openai
77+
name: gpt-4o
78+
organization_id: "org-xxxxxxxxxxxxxxxxxxxxxxxx"
79+
```
80+
81+
Or via environment variable (overrides YAML):
82+
83+
```bash
84+
export OPENAI_ORG_ID=org-xxxxxxxxxxxxxxxxxxxxxxxx
85+
```
86+
87+
The `OpenAI-Organization` header is sent on all OpenAI API requests (chat, embeddings, responses). Fallback providers inherit the primary org ID unless overridden per-fallback. The org ID is also injected into skill subprocess environments as `OPENAI_ORG_ID`.
88+
7089
### Fallback Chains
7190

7291
Configure fallback providers for automatic failover when the primary provider is unavailable:

docs/tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ tools:
7777
| 3 | **Argument validation** | Rejects arguments containing `$(`, backticks, or newlines |
7878
| 4 | **Timeout** | Configurable per-command timeout (default: 120s) |
7979
| 5 | **No shell** | Uses `exec.CommandContext` directly — no shell expansion |
80-
| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, and proxy vars |
80+
| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, proxy vars, and `OPENAI_ORG_ID` (when set) |
8181
| 7 | **Output limits** | Configurable max output size (default: 1MB) to prevent memory exhaustion |
8282

8383
## Memory Tools

forge-cli/cmd/init.go

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type initOptions struct {
3333
Language string
3434
ModelProvider string
3535
APIKey string // validated provider key
36+
OrganizationID string // OpenAI enterprise organization ID
3637
Fallbacks []tui.FallbackProvider
3738
Channels []string
3839
SkillsFile string
@@ -54,21 +55,22 @@ type toolEntry struct {
5455

5556
// templateData is passed to all templates during rendering.
5657
type templateData struct {
57-
Name string
58-
AgentID string
59-
Framework string
60-
Language string
61-
Entrypoint string
62-
ModelProvider string
63-
ModelName string
64-
Fallbacks []fallbackTmplData
65-
Channels []string
66-
Tools []toolEntry
67-
BuiltinTools []string
68-
SkillEntries []skillTmplData
69-
EgressDomains []string
70-
EnvVars []envVarEntry
71-
HasSecrets bool
58+
Name string
59+
AgentID string
60+
Framework string
61+
Language string
62+
Entrypoint string
63+
ModelProvider string
64+
ModelName string
65+
OrganizationID string
66+
Fallbacks []fallbackTmplData
67+
Channels []string
68+
Tools []toolEntry
69+
BuiltinTools []string
70+
SkillEntries []skillTmplData
71+
EgressDomains []string
72+
EnvVars []envVarEntry
73+
HasSecrets bool
7274
}
7375

7476
// fallbackTmplData holds template data for a fallback provider.
@@ -116,6 +118,7 @@ func init() {
116118
initCmd.Flags().StringSlice("tools", nil, "builtin tools to enable (e.g., web_search,http_request)")
117119
initCmd.Flags().StringSlice("skills", nil, "registry skills to include (e.g., github,weather)")
118120
initCmd.Flags().String("api-key", "", "LLM provider API key")
121+
initCmd.Flags().String("org-id", "", "OpenAI organization ID (enterprise)")
119122
initCmd.Flags().StringSlice("fallbacks", nil, "fallback LLM providers (e.g., openai,gemini)")
120123
initCmd.Flags().Bool("force", false, "overwrite existing directory")
121124
}
@@ -142,6 +145,7 @@ func runInit(cmd *cobra.Command, args []string) error {
142145
opts.BuiltinTools, _ = cmd.Flags().GetStringSlice("tools")
143146
opts.Skills, _ = cmd.Flags().GetStringSlice("skills")
144147
opts.APIKey, _ = cmd.Flags().GetString("api-key")
148+
opts.OrganizationID, _ = cmd.Flags().GetString("org-id")
145149
fallbackProviders, _ := cmd.Flags().GetStringSlice("fallbacks")
146150
for _, p := range fallbackProviders {
147151
opts.Fallbacks = append(opts.Fallbacks, tui.FallbackProvider{Provider: p})
@@ -286,6 +290,7 @@ func collectInteractive(opts *initOptions) error {
286290
opts.ModelProvider = ctx.Provider
287291
opts.APIKey = ctx.APIKey
288292
opts.AuthMethod = ctx.AuthMethod
293+
opts.OrganizationID = ctx.OrganizationID
289294
opts.Fallbacks = ctx.Fallbacks
290295
opts.CustomModel = ctx.CustomModel
291296
// Use wizard-selected model name if available
@@ -928,14 +933,15 @@ func getFileManifest(opts *initOptions) []fileToRender {
928933

929934
func buildTemplateData(opts *initOptions) templateData {
930935
data := templateData{
931-
Name: opts.Name,
932-
AgentID: opts.AgentID,
933-
Framework: opts.Framework,
934-
Language: opts.Language,
935-
ModelProvider: opts.ModelProvider,
936-
Channels: opts.Channels,
937-
Tools: opts.Tools,
938-
BuiltinTools: opts.BuiltinTools,
936+
Name: opts.Name,
937+
AgentID: opts.AgentID,
938+
Framework: opts.Framework,
939+
Language: opts.Language,
940+
ModelProvider: opts.ModelProvider,
941+
OrganizationID: opts.OrganizationID,
942+
Channels: opts.Channels,
943+
Tools: opts.Tools,
944+
BuiltinTools: opts.BuiltinTools,
939945
}
940946

941947
// Set entrypoint based on framework (only for subprocess-based frameworks)
@@ -1033,6 +1039,9 @@ func buildEnvVars(opts *initOptions) []envVarEntry {
10331039
val = "your-api-key-here"
10341040
}
10351041
vars = append(vars, envVarEntry{Key: "OPENAI_API_KEY", Value: val, Comment: "OpenAI API key"})
1042+
if orgID := opts.OrganizationID; orgID != "" {
1043+
vars = append(vars, envVarEntry{Key: "OPENAI_ORG_ID", Value: orgID, Comment: "OpenAI organization ID (enterprise)"})
1044+
}
10361045
case "anthropic":
10371046
val := opts.EnvVars["ANTHROPIC_API_KEY"]
10381047
if val == "" {

forge-cli/cmd/ui.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ func runUI(cmd *cobra.Command, args []string) error {
116116
CustomModel: opts.ModelName,
117117
APIKey: opts.APIKey,
118118
AuthMethod: opts.AuthMethod,
119+
OrganizationID: opts.OrganizationID,
119120
Fallbacks: fallbacks,
120121
Channels: opts.Channels,
121122
BuiltinTools: opts.BuiltinTools,
@@ -136,6 +137,11 @@ func runUI(cmd *cobra.Command, args []string) error {
136137
initOpts.EnvVars["WEB_SEARCH_PROVIDER"] = opts.WebSearchProvider
137138
}
138139

140+
// Store organization ID for OpenAI enterprise
141+
if opts.OrganizationID != "" {
142+
initOpts.EnvVars["OPENAI_ORG_ID"] = opts.OrganizationID
143+
}
144+
139145
// Set passphrase for secret encryption if provided
140146
if opts.Passphrase != "" {
141147
_ = os.Setenv("FORGE_PASSPHRASE", opts.Passphrase)

forge-cli/internal/tui/components/multi_select.go

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ type MultiSelectItem struct {
2222
type MultiSelect struct {
2323
Items []MultiSelectItem
2424
cursor int
25+
offset int // index of first visible item
26+
height int // terminal height (0 = no constraint)
2527
done bool
2628

2729
// Styles
@@ -59,21 +61,59 @@ func (m *MultiSelect) Init() tea.Cmd {
5961
return nil
6062
}
6163

64+
// maxVisibleItems returns how many items fit in the viewport.
65+
func (m MultiSelect) maxVisibleItems() int {
66+
if m.height <= 0 || len(m.Items) == 0 {
67+
return len(m.Items)
68+
}
69+
// Each item ≈ 4 lines (border top, content, border bottom, gap).
70+
// Reserve ~18 lines for wizard chrome (banner, progress, kbd hints, padding).
71+
available := (m.height - 18) / 4
72+
if available < 3 {
73+
available = 3
74+
}
75+
if available >= len(m.Items) {
76+
return len(m.Items)
77+
}
78+
return available
79+
}
80+
81+
// adjustOffset ensures the cursor is within the visible window.
82+
func (m *MultiSelect) adjustOffset() {
83+
maxVisible := m.maxVisibleItems()
84+
if m.cursor < m.offset {
85+
m.offset = m.cursor
86+
}
87+
if m.cursor >= m.offset+maxVisible {
88+
m.offset = m.cursor - maxVisible + 1
89+
}
90+
if m.offset < 0 {
91+
m.offset = 0
92+
}
93+
}
94+
6295
// Update handles keyboard input.
6396
func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) {
6497
if m.done {
6598
return m, nil
6699
}
67100

68-
if msg, ok := msg.(tea.KeyMsg); ok {
101+
switch msg := msg.(type) {
102+
case tea.WindowSizeMsg:
103+
m.height = msg.Height
104+
m.adjustOffset()
105+
return m, nil
106+
case tea.KeyMsg:
69107
switch msg.String() {
70108
case "up", "k":
71109
if m.cursor > 0 {
72110
m.cursor--
111+
m.adjustOffset()
73112
}
74113
case "down", "j":
75114
if m.cursor < len(m.Items)-1 {
76115
m.cursor++
116+
m.adjustOffset()
77117
}
78118
case " ":
79119
m.Items[m.cursor].Checked = !m.Items[m.cursor].Checked
@@ -99,14 +139,28 @@ func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) {
99139

100140
// View renders the multi-select list.
101141
func (m MultiSelect) View(width int) string {
102-
var out string
142+
var b strings.Builder
103143

104144
itemWidth := width - 6
105145
if itemWidth < 30 {
106146
itemWidth = 30
107147
}
108148

109-
for i, item := range m.Items {
149+
maxVisible := m.maxVisibleItems()
150+
start := m.offset
151+
end := start + maxVisible
152+
if end > len(m.Items) {
153+
end = len(m.Items)
154+
}
155+
156+
// Scroll indicator: items above
157+
if start > 0 {
158+
hint := fmt.Sprintf(" ▲ %d more above", start)
159+
b.WriteString(lipgloss.NewStyle().Foreground(m.DimColor).Render(hint) + "\n")
160+
}
161+
162+
for i := start; i < end; i++ {
163+
item := m.Items[i]
110164
isCursor := i == m.cursor
111165
var checkbox, icon, label, desc string
112166

@@ -148,11 +202,17 @@ func (m MultiSelect) View(width int) string {
148202
border = m.InactiveBorder.Width(itemWidth)
149203
}
150204

151-
out += " " + border.Render(content) + "\n"
205+
b.WriteString(" " + border.Render(content) + "\n")
206+
}
207+
208+
// Scroll indicator: items below
209+
if end < len(m.Items) {
210+
hint := fmt.Sprintf(" ▼ %d more below", len(m.Items)-end)
211+
b.WriteString(lipgloss.NewStyle().Foreground(m.DimColor).Render(hint) + "\n")
152212
}
153213

154-
out += "\n" + m.kbd.View()
155-
return out
214+
b.WriteString("\n" + m.kbd.View())
215+
return b.String()
156216
}
157217

158218
// Done returns true when selection is confirmed.

0 commit comments

Comments
 (0)