From a7358722d0ea69c80485ca5662815b031a2bf2ba Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 3 Mar 2026 20:28:13 -0500 Subject: [PATCH] feat: add OpenAI Enterprise org ID support and TUI scroll fix Wire organization_id config field and OPENAI_ORG_ID env var through the full stack: config types, resolution, validation, OpenAI chat/ embedder/responses clients (OpenAI-Organization header), runner, skill executor env injection, audit logging, CLI wizard, init scaffolding, and web dashboard. Add viewport scrolling to TUI MultiSelect and SingleSelect components so the wizard doesn't overflow on small terminals, with scroll indicators and WindowSizeMsg forwarding from wizard to steps. Update docs to reflect new configuration, flags, and behavior. --- docs/commands.md | 8 ++ docs/configuration.md | 3 + docs/dashboard.md | 2 +- docs/hooks.md | 14 ++ docs/runtime.md | 21 ++- docs/tools.md | 2 +- forge-cli/cmd/init.go | 55 ++++---- forge-cli/cmd/ui.go | 6 + .../internal/tui/components/multi_select.go | 72 ++++++++++- .../internal/tui/components/single_select.go | 72 ++++++++++- forge-cli/internal/tui/steps/channel_step.go | 6 + forge-cli/internal/tui/steps/fallback_step.go | 14 ++ forge-cli/internal/tui/steps/provider_step.go | 70 ++++++++++ forge-cli/internal/tui/steps/skills_step.go | 6 + forge-cli/internal/tui/steps/tools_step.go | 14 ++ forge-cli/internal/tui/wizard.go | 36 +++--- forge-cli/runtime/runner.go | 9 ++ forge-cli/templates/init/env.example.tmpl | 1 + forge-cli/templates/init/forge.yaml.tmpl | 3 + forge-cli/tools/exec.go | 3 + forge-cli/tools/exec_test.go | 41 ++++++ forge-core/llm/providers/openai_embedder.go | 6 + .../llm/providers/openai_embedder_test.go | 65 ++++++++++ forge-core/llm/providers/openai_test.go | 63 +++++++++ forge-core/llm/providers/responses.go | 5 + forge-core/llm/providers/responses_test.go | 65 ++++++++++ forge-core/runtime/config.go | 27 +++- forge-core/runtime/config_test.go | 122 ++++++++++++++++++ forge-core/types/config.go | 14 +- forge-core/validate/forge_config.go | 4 + forge-core/validate/forge_config_test.go | 35 +++++ forge-ui/handlers_create.go | 7 +- forge-ui/static/dist/app.js | 14 ++ forge-ui/types.go | 16 ++- 34 files changed, 828 insertions(+), 73 deletions(-) create mode 100644 forge-cli/tools/exec_test.go create mode 100644 forge-core/llm/providers/openai_embedder_test.go create mode 100644 forge-core/llm/providers/openai_test.go create mode 100644 forge-core/llm/providers/responses_test.go diff --git a/docs/commands.md b/docs/commands.md index 30cea98..ab9d022 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -34,6 +34,7 @@ forge init [name] [flags] | `--tools` | | | Builtin tools to enable (e.g., `web_search,http_request`) | | `--skills` | | | Registry skills to include (e.g., `github,weather`) | | `--api-key` | | | LLM provider API key | +| `--org-id` | | | OpenAI Organization ID (enterprise) | | `--from-skills` | | | Path to a SKILL.md file for auto-configuration | | `--non-interactive` | | `false` | Skip interactive prompts | @@ -62,6 +63,13 @@ forge init my-agent \ --skills github \ --api-key sk-... \ --non-interactive + +# OpenAI enterprise with organization ID +forge init my-agent \ + --model-provider openai \ + --api-key sk-... \ + --org-id org-xxxxxxxxxxxxxxxxxxxxxxxx \ + --non-interactive ``` --- diff --git a/docs/configuration.md b/docs/configuration.md index 18991d3..8c473d5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,9 +16,11 @@ entrypoint: "agent.py" # Required for crewai/langchain, omit for fo model: provider: "openai" # openai, anthropic, gemini, ollama, custom name: "gpt-4o" # Model name + organization_id: "org-xxx" # OpenAI Organization ID (enterprise, optional) fallbacks: # Fallback providers (optional) - provider: "anthropic" name: "claude-sonnet-4-20250514" + organization_id: "" # Per-fallback org ID override (optional) tools: - name: "web_search" @@ -80,6 +82,7 @@ schedules: # Recurring scheduled tasks (optional) | `FORGE_MEMORY_LONG_TERM` | Set `true` to enable long-term memory | | `FORGE_EMBEDDING_PROVIDER` | Override embedding provider | | `OPENAI_API_KEY` | OpenAI API key | +| `OPENAI_ORG_ID` | OpenAI Organization ID (enterprise); overrides `organization_id` in YAML | | `ANTHROPIC_API_KEY` | Anthropic API key | | `GEMINI_API_KEY` | Google Gemini API key | | `TAVILY_API_KEY` | Tavily web search API key | diff --git a/docs/dashboard.md b/docs/dashboard.md index 01fd307..a74681d 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -50,7 +50,7 @@ A multi-step wizard (web equivalent of `forge init`) that walks through the full |------|-------------| | Name | Set agent name with live slug preview | | Provider | Select LLM provider (OpenAI, Anthropic, Gemini, Ollama, Custom) with descriptions | -| Model & Auth | Pick from provider-specific model lists; OpenAI supports API key or browser OAuth login | +| Model & Auth | Pick from provider-specific model lists; OpenAI supports API key or browser OAuth login, plus optional Organization ID for enterprise accounts | | Channels | Select Slack/Telegram with inline token collection | | Tools | Select builtin tools; web_search shows Tavily vs Perplexity provider choice with API key input | | Skills | Browse registry skills by category with inline required/optional env var collection | diff --git a/docs/hooks.md b/docs/hooks.md index 1de19d3..ed9e73d 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -73,6 +73,20 @@ hooks.Register(engine.BeforeToolExec, func(ctx context.Context, hctx *engine.Hoo }) ``` +## Audit Logging + +The runner registers `AfterLLMCall` hooks that emit structured audit events for each LLM interaction. Audit fields include: + +| Field | Description | +|-------|-------------| +| `provider` | LLM provider name | +| `model` | Model identifier | +| `input_tokens` | Prompt token count | +| `output_tokens` | Completion token count | +| `organization_id` | OpenAI Organization ID (when set) | + +These events are logged via `slog` at Info level and can be consumed by external log aggregators for cost tracking and compliance. + ## Progress Tracking 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. diff --git a/docs/runtime.md b/docs/runtime.md index d08fd13..0c38be3 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -27,7 +27,7 @@ Forge supports multiple LLM providers with automatic fallback: | Provider | Default Model | Auth | |----------|--------------|------| -| `openai` | `gpt-5.2-2025-12-11` | API key or OAuth | +| `openai` | `gpt-5.2-2025-12-11` | API key or OAuth; optional Organization ID | | `anthropic` | `claude-sonnet-4-20250514` | API key | | `gemini` | `gemini-2.5-flash` | API key | | `ollama` | `llama3` | None (local) | @@ -67,6 +67,25 @@ forge init my-agent OAuth tokens are stored in `~/.forge/credentials/openai.json` and automatically refreshed. +### Organization ID (OpenAI Enterprise) + +Enterprise OpenAI accounts can set an Organization ID to route API requests to the correct org: + +```yaml +model: + provider: openai + name: gpt-4o + organization_id: "org-xxxxxxxxxxxxxxxxxxxxxxxx" +``` + +Or via environment variable (overrides YAML): + +```bash +export OPENAI_ORG_ID=org-xxxxxxxxxxxxxxxxxxxxxxxx +``` + +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`. + ### Fallback Chains Configure fallback providers for automatic failover when the primary provider is unavailable: diff --git a/docs/tools.md b/docs/tools.md index 030cfd9..2fcddc0 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -77,7 +77,7 @@ tools: | 3 | **Argument validation** | Rejects arguments containing `$(`, backticks, or newlines | | 4 | **Timeout** | Configurable per-command timeout (default: 120s) | | 5 | **No shell** | Uses `exec.CommandContext` directly — no shell expansion | -| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, and proxy vars | +| 6 | **Environment isolation** | Only `PATH`, `HOME`, `LANG`, explicit passthrough vars, proxy vars, and `OPENAI_ORG_ID` (when set) | | 7 | **Output limits** | Configurable max output size (default: 1MB) to prevent memory exhaustion | ## Memory Tools diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 313fb25..7f7b649 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -33,6 +33,7 @@ type initOptions struct { Language string ModelProvider string APIKey string // validated provider key + OrganizationID string // OpenAI enterprise organization ID Fallbacks []tui.FallbackProvider Channels []string SkillsFile string @@ -54,21 +55,22 @@ type toolEntry struct { // templateData is passed to all templates during rendering. type templateData struct { - Name string - AgentID string - Framework string - Language string - Entrypoint string - ModelProvider string - ModelName string - Fallbacks []fallbackTmplData - Channels []string - Tools []toolEntry - BuiltinTools []string - SkillEntries []skillTmplData - EgressDomains []string - EnvVars []envVarEntry - HasSecrets bool + Name string + AgentID string + Framework string + Language string + Entrypoint string + ModelProvider string + ModelName string + OrganizationID string + Fallbacks []fallbackTmplData + Channels []string + Tools []toolEntry + BuiltinTools []string + SkillEntries []skillTmplData + EgressDomains []string + EnvVars []envVarEntry + HasSecrets bool } // fallbackTmplData holds template data for a fallback provider. @@ -116,6 +118,7 @@ func init() { initCmd.Flags().StringSlice("tools", nil, "builtin tools to enable (e.g., web_search,http_request)") initCmd.Flags().StringSlice("skills", nil, "registry skills to include (e.g., github,weather)") initCmd.Flags().String("api-key", "", "LLM provider API key") + initCmd.Flags().String("org-id", "", "OpenAI organization ID (enterprise)") initCmd.Flags().StringSlice("fallbacks", nil, "fallback LLM providers (e.g., openai,gemini)") initCmd.Flags().Bool("force", false, "overwrite existing directory") } @@ -142,6 +145,7 @@ func runInit(cmd *cobra.Command, args []string) error { opts.BuiltinTools, _ = cmd.Flags().GetStringSlice("tools") opts.Skills, _ = cmd.Flags().GetStringSlice("skills") opts.APIKey, _ = cmd.Flags().GetString("api-key") + opts.OrganizationID, _ = cmd.Flags().GetString("org-id") fallbackProviders, _ := cmd.Flags().GetStringSlice("fallbacks") for _, p := range fallbackProviders { opts.Fallbacks = append(opts.Fallbacks, tui.FallbackProvider{Provider: p}) @@ -286,6 +290,7 @@ func collectInteractive(opts *initOptions) error { opts.ModelProvider = ctx.Provider opts.APIKey = ctx.APIKey opts.AuthMethod = ctx.AuthMethod + opts.OrganizationID = ctx.OrganizationID opts.Fallbacks = ctx.Fallbacks opts.CustomModel = ctx.CustomModel // Use wizard-selected model name if available @@ -928,14 +933,15 @@ func getFileManifest(opts *initOptions) []fileToRender { func buildTemplateData(opts *initOptions) templateData { data := templateData{ - Name: opts.Name, - AgentID: opts.AgentID, - Framework: opts.Framework, - Language: opts.Language, - ModelProvider: opts.ModelProvider, - Channels: opts.Channels, - Tools: opts.Tools, - BuiltinTools: opts.BuiltinTools, + Name: opts.Name, + AgentID: opts.AgentID, + Framework: opts.Framework, + Language: opts.Language, + ModelProvider: opts.ModelProvider, + OrganizationID: opts.OrganizationID, + Channels: opts.Channels, + Tools: opts.Tools, + BuiltinTools: opts.BuiltinTools, } // Set entrypoint based on framework (only for subprocess-based frameworks) @@ -1033,6 +1039,9 @@ func buildEnvVars(opts *initOptions) []envVarEntry { val = "your-api-key-here" } vars = append(vars, envVarEntry{Key: "OPENAI_API_KEY", Value: val, Comment: "OpenAI API key"}) + if orgID := opts.OrganizationID; orgID != "" { + vars = append(vars, envVarEntry{Key: "OPENAI_ORG_ID", Value: orgID, Comment: "OpenAI organization ID (enterprise)"}) + } case "anthropic": val := opts.EnvVars["ANTHROPIC_API_KEY"] if val == "" { diff --git a/forge-cli/cmd/ui.go b/forge-cli/cmd/ui.go index a44186a..3632af5 100644 --- a/forge-cli/cmd/ui.go +++ b/forge-cli/cmd/ui.go @@ -116,6 +116,7 @@ func runUI(cmd *cobra.Command, args []string) error { CustomModel: opts.ModelName, APIKey: opts.APIKey, AuthMethod: opts.AuthMethod, + OrganizationID: opts.OrganizationID, Fallbacks: fallbacks, Channels: opts.Channels, BuiltinTools: opts.BuiltinTools, @@ -136,6 +137,11 @@ func runUI(cmd *cobra.Command, args []string) error { initOpts.EnvVars["WEB_SEARCH_PROVIDER"] = opts.WebSearchProvider } + // Store organization ID for OpenAI enterprise + if opts.OrganizationID != "" { + initOpts.EnvVars["OPENAI_ORG_ID"] = opts.OrganizationID + } + // Set passphrase for secret encryption if provided if opts.Passphrase != "" { _ = os.Setenv("FORGE_PASSPHRASE", opts.Passphrase) diff --git a/forge-cli/internal/tui/components/multi_select.go b/forge-cli/internal/tui/components/multi_select.go index 54340b2..15dd203 100644 --- a/forge-cli/internal/tui/components/multi_select.go +++ b/forge-cli/internal/tui/components/multi_select.go @@ -22,6 +22,8 @@ type MultiSelectItem struct { type MultiSelect struct { Items []MultiSelectItem cursor int + offset int // index of first visible item + height int // terminal height (0 = no constraint) done bool // Styles @@ -59,21 +61,59 @@ func (m *MultiSelect) Init() tea.Cmd { return nil } +// maxVisibleItems returns how many items fit in the viewport. +func (m MultiSelect) maxVisibleItems() int { + if m.height <= 0 || len(m.Items) == 0 { + return len(m.Items) + } + // Each item ≈ 4 lines (border top, content, border bottom, gap). + // Reserve ~18 lines for wizard chrome (banner, progress, kbd hints, padding). + available := (m.height - 18) / 4 + if available < 3 { + available = 3 + } + if available >= len(m.Items) { + return len(m.Items) + } + return available +} + +// adjustOffset ensures the cursor is within the visible window. +func (m *MultiSelect) adjustOffset() { + maxVisible := m.maxVisibleItems() + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+maxVisible { + m.offset = m.cursor - maxVisible + 1 + } + if m.offset < 0 { + m.offset = 0 + } +} + // Update handles keyboard input. func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) { if m.done { return m, nil } - if msg, ok := msg.(tea.KeyMsg); ok { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + m.adjustOffset() + return m, nil + case tea.KeyMsg: switch msg.String() { case "up", "k": if m.cursor > 0 { m.cursor-- + m.adjustOffset() } case "down", "j": if m.cursor < len(m.Items)-1 { m.cursor++ + m.adjustOffset() } case " ": m.Items[m.cursor].Checked = !m.Items[m.cursor].Checked @@ -99,14 +139,28 @@ func (m MultiSelect) Update(msg tea.Msg) (MultiSelect, tea.Cmd) { // View renders the multi-select list. func (m MultiSelect) View(width int) string { - var out string + var b strings.Builder itemWidth := width - 6 if itemWidth < 30 { itemWidth = 30 } - for i, item := range m.Items { + maxVisible := m.maxVisibleItems() + start := m.offset + end := start + maxVisible + if end > len(m.Items) { + end = len(m.Items) + } + + // Scroll indicator: items above + if start > 0 { + hint := fmt.Sprintf(" ▲ %d more above", start) + b.WriteString(lipgloss.NewStyle().Foreground(m.DimColor).Render(hint) + "\n") + } + + for i := start; i < end; i++ { + item := m.Items[i] isCursor := i == m.cursor var checkbox, icon, label, desc string @@ -148,11 +202,17 @@ func (m MultiSelect) View(width int) string { border = m.InactiveBorder.Width(itemWidth) } - out += " " + border.Render(content) + "\n" + b.WriteString(" " + border.Render(content) + "\n") + } + + // Scroll indicator: items below + if end < len(m.Items) { + hint := fmt.Sprintf(" ▼ %d more below", len(m.Items)-end) + b.WriteString(lipgloss.NewStyle().Foreground(m.DimColor).Render(hint) + "\n") } - out += "\n" + m.kbd.View() - return out + b.WriteString("\n" + m.kbd.View()) + return b.String() } // Done returns true when selection is confirmed. diff --git a/forge-cli/internal/tui/components/single_select.go b/forge-cli/internal/tui/components/single_select.go index 39b3d04..e0a45a8 100644 --- a/forge-cli/internal/tui/components/single_select.go +++ b/forge-cli/internal/tui/components/single_select.go @@ -20,6 +20,8 @@ type SingleSelectItem struct { type SingleSelect struct { Items []SingleSelectItem cursor int + offset int // index of first visible item + height int // terminal height (0 = no constraint) selected int done bool @@ -65,21 +67,59 @@ func (s *SingleSelect) Init() tea.Cmd { return nil } +// maxVisibleItems returns how many items fit in the viewport. +func (s SingleSelect) maxVisibleItems() int { + if s.height <= 0 || len(s.Items) == 0 { + return len(s.Items) + } + // Each item ≈ 4 lines (border top, content, border bottom, gap). + // Reserve ~18 lines for wizard chrome (banner, progress, kbd hints, padding). + available := (s.height - 18) / 4 + if available < 3 { + available = 3 + } + if available >= len(s.Items) { + return len(s.Items) + } + return available +} + +// adjustOffset ensures the cursor is within the visible window. +func (s *SingleSelect) adjustOffset() { + maxVisible := s.maxVisibleItems() + if s.cursor < s.offset { + s.offset = s.cursor + } + if s.cursor >= s.offset+maxVisible { + s.offset = s.cursor - maxVisible + 1 + } + if s.offset < 0 { + s.offset = 0 + } +} + // Update handles keyboard input. func (s SingleSelect) Update(msg tea.Msg) (SingleSelect, tea.Cmd) { if s.done { return s, nil } - if msg, ok := msg.(tea.KeyMsg); ok { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.height = msg.Height + s.adjustOffset() + return s, nil + case tea.KeyMsg: switch msg.String() { case "up", "k": if s.cursor > 0 { s.cursor-- + s.adjustOffset() } case "down", "j": if s.cursor < len(s.Items)-1 { s.cursor++ + s.adjustOffset() } case "enter": s.selected = s.cursor @@ -92,14 +132,28 @@ func (s SingleSelect) Update(msg tea.Msg) (SingleSelect, tea.Cmd) { // View renders the select list. func (s SingleSelect) View(width int) string { - var out string + var b strings.Builder itemWidth := width - 6 if itemWidth < 30 { itemWidth = 30 } - for i, item := range s.Items { + maxVisible := s.maxVisibleItems() + start := s.offset + end := start + maxVisible + if end > len(s.Items) { + end = len(s.Items) + } + + // Scroll indicator: items above + if start > 0 { + hint := fmt.Sprintf(" ▲ %d more above", start) + b.WriteString(lipgloss.NewStyle().Foreground(s.DimColor).Render(hint) + "\n") + } + + for i := start; i < end; i++ { + item := s.Items[i] isCursor := i == s.cursor var radio, icon, label, desc string @@ -133,11 +187,17 @@ func (s SingleSelect) View(width int) string { border = s.InactiveBorder.Width(itemWidth) } - out += " " + border.Render(content) + "\n" + b.WriteString(" " + border.Render(content) + "\n") + } + + // Scroll indicator: items below + if end < len(s.Items) { + hint := fmt.Sprintf(" ▼ %d more below", len(s.Items)-end) + b.WriteString(lipgloss.NewStyle().Foreground(s.DimColor).Render(hint) + "\n") } - out += "\n" + s.kbd.View() - return out + b.WriteString("\n" + s.kbd.View()) + return b.String() } // Done returns true when a selection has been made. diff --git a/forge-cli/internal/tui/steps/channel_step.go b/forge-cli/internal/tui/steps/channel_step.go index 402c97c..6a06293 100644 --- a/forge-cli/internal/tui/steps/channel_step.go +++ b/forge-cli/internal/tui/steps/channel_step.go @@ -69,6 +69,12 @@ func (s *ChannelStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s, nil } + if wsm, ok := msg.(tea.WindowSizeMsg); ok && s.phase == channelSelectPhase { + updated, cmd := s.selector.Update(wsm) + s.selector = updated + return s, cmd + } + switch s.phase { case channelSelectPhase: return s.updateSelectPhase(msg) diff --git a/forge-cli/internal/tui/steps/fallback_step.go b/forge-cli/internal/tui/steps/fallback_step.go index b55cdc1..543cede 100644 --- a/forge-cli/internal/tui/steps/fallback_step.go +++ b/forge-cli/internal/tui/steps/fallback_step.go @@ -82,6 +82,20 @@ func (s *FallbackStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s, nil } + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + switch s.phase { + case fallbackAskPhase: + updated, cmd := s.askSelector.Update(wsm) + s.askSelector = updated + return s, cmd + case fallbackSelectPhase: + updated, cmd := s.multiSelector.Update(wsm) + s.multiSelector = updated + return s, cmd + } + return s, nil + } + switch s.phase { case fallbackAskPhase: return s.updateAskPhase(msg) diff --git a/forge-cli/internal/tui/steps/provider_step.go b/forge-cli/internal/tui/steps/provider_step.go index 4cae6a2..73e5900 100644 --- a/forge-cli/internal/tui/steps/provider_step.go +++ b/forge-cli/internal/tui/steps/provider_step.go @@ -2,6 +2,7 @@ package steps import ( "fmt" + "strings" tea "github.com/charmbracelet/bubbletea" @@ -18,6 +19,7 @@ const ( providerValidatingPhase providerOAuthPhase providerModelPhase + providerOrgIDPhase providerCustomURLPhase providerCustomModelPhase providerCustomAuthPhase @@ -65,6 +67,7 @@ type ProviderStep struct { apiKey string authMethod string // "apikey" or "oauth" modelID string // selected model ID + orgID string // OpenAI enterprise organization ID customURL string customModel string customAuth string @@ -124,6 +127,24 @@ func (s *ProviderStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s, nil } + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + switch s.phase { + case providerSelectPhase: + updated, cmd := s.selector.Update(wsm) + s.selector = updated + return s, cmd + case providerAuthMethodPhase: + updated, cmd := s.authMethodSelector.Update(wsm) + s.authMethodSelector = updated + return s, cmd + case providerModelPhase: + updated, cmd := s.modelSelector.Update(wsm) + s.modelSelector = updated + return s, cmd + } + return s, nil + } + switch s.phase { case providerSelectPhase: return s.updateSelectPhase(msg) @@ -137,6 +158,8 @@ func (s *ProviderStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s.updateOAuthPhase(msg) case providerModelPhase: return s.updateModelPhase(msg) + case providerOrgIDPhase: + return s.updateOrgIDPhase(msg) case providerCustomURLPhase: return s.updateCustomURLPhase(msg) case providerCustomModelPhase: @@ -417,6 +440,47 @@ func (s *ProviderStep) updateModelPhase(msg tea.Msg) (tui.Step, tea.Cmd) { if s.modelSelector.Done() { _, val := s.modelSelector.Selected() s.modelID = val + // Show org ID prompt for API key auth + if s.authMethod == "apikey" { + return s, s.showOrgIDPrompt() + } + s.complete = true + return s, func() tea.Msg { return tui.StepCompleteMsg{} } + } + + return s, cmd +} + +// showOrgIDPrompt sets up the org ID text input phase. +func (s *ProviderStep) showOrgIDPrompt() tea.Cmd { + s.phase = providerOrgIDPhase + s.textInput = components.NewTextInput( + "OpenAI Organization ID (optional — press Enter to skip)", + "org-xxxxxxxxxxxxxxxxxxxxxxxx", + false, // no slug hint + func(val string) error { + if val != "" && !strings.HasPrefix(val, "org-") { + return fmt.Errorf("must start with org-") + } + return nil + }, + s.styles.Theme.Accent, + s.styles.AccentTxt, + s.styles.InactiveBorder, + s.styles.ErrorTxt, + s.styles.DimTxt, + s.styles.KbdKey, + s.styles.KbdDesc, + ) + return s.textInput.Init() +} + +func (s *ProviderStep) updateOrgIDPhase(msg tea.Msg) (tui.Step, tea.Cmd) { + updated, cmd := s.textInput.Update(msg) + s.textInput = updated + + if s.textInput.Done() { + s.orgID = s.textInput.Value() s.complete = true return s, func() tea.Msg { return tui.StepCompleteMsg{} } } @@ -523,6 +587,8 @@ func (s *ProviderStep) View(width int) string { return "" case providerModelPhase: return s.modelSelector.View(width) + case providerOrgIDPhase: + return s.textInput.View(width) case providerCustomURLPhase, providerCustomModelPhase: return s.textInput.View(width) case providerCustomAuthPhase: @@ -563,6 +629,7 @@ func (s *ProviderStep) Apply(ctx *tui.WizardContext) { ctx.APIKey = s.apiKey ctx.AuthMethod = s.authMethod ctx.ModelName = s.modelID + ctx.OrganizationID = s.orgID ctx.CustomBaseURL = s.customURL ctx.CustomModel = s.customModel ctx.CustomAPIKey = s.customAuth @@ -579,6 +646,9 @@ func (s *ProviderStep) Apply(ctx *tui.WizardContext) { ctx.EnvVars["GEMINI_API_KEY"] = s.apiKey } } + if s.orgID != "" { + ctx.EnvVars["OPENAI_ORG_ID"] = s.orgID + } } // modelDisplayName returns the user-friendly name for a model ID. diff --git a/forge-cli/internal/tui/steps/skills_step.go b/forge-cli/internal/tui/steps/skills_step.go index ca0de1b..37065ec 100644 --- a/forge-cli/internal/tui/steps/skills_step.go +++ b/forge-cli/internal/tui/steps/skills_step.go @@ -145,6 +145,12 @@ func (s *SkillsStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s, nil } + if msg, ok := msg.(tea.WindowSizeMsg); ok && s.phase == skillsSelectPhase { + updated, cmd := s.multiSelect.Update(msg) + s.multiSelect = updated + return s, cmd + } + switch s.phase { case skillsSelectPhase: updated, cmd := s.multiSelect.Update(msg) diff --git a/forge-cli/internal/tui/steps/tools_step.go b/forge-cli/internal/tui/steps/tools_step.go index dde89ca..5ab4b78 100644 --- a/forge-cli/internal/tui/steps/tools_step.go +++ b/forge-cli/internal/tui/steps/tools_step.go @@ -91,6 +91,20 @@ func (s *ToolsStep) Update(msg tea.Msg) (tui.Step, tea.Cmd) { return s, nil } + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + switch s.phase { + case toolsSelectPhase: + updated, cmd := s.multiSelect.Update(wsm) + s.multiSelect = updated + return s, cmd + case toolsWebSearchProviderPhase: + updated, cmd := s.providerSelect.Update(wsm) + s.providerSelect = updated + return s, cmd + } + return s, nil + } + switch s.phase { case toolsSelectPhase: updated, cmd := s.multiSelect.Update(msg) diff --git a/forge-cli/internal/tui/wizard.go b/forge-cli/internal/tui/wizard.go index e455e67..58b6cad 100644 --- a/forge-cli/internal/tui/wizard.go +++ b/forge-cli/internal/tui/wizard.go @@ -14,21 +14,22 @@ type FallbackProvider struct { // WizardContext accumulates all data across wizard steps. type WizardContext struct { - Name string - Provider string - APIKey string - AuthMethod string // "apikey" or "oauth" — how the user authenticated - ModelName string // selected model ID (e.g. "gpt-5.3-codex") - Fallbacks []FallbackProvider - Channel string - ChannelTokens map[string]string - BuiltinTools []string - Skills []string - EgressDomains []string - CustomBaseURL string - CustomModel string - CustomAPIKey string - EnvVars map[string]string + Name string + Provider string + APIKey string + AuthMethod string // "apikey" or "oauth" — how the user authenticated + ModelName string // selected model ID (e.g. "gpt-5.3-codex") + OrganizationID string // OpenAI enterprise organization ID + Fallbacks []FallbackProvider + Channel string + ChannelTokens map[string]string + BuiltinTools []string + Skills []string + EgressDomains []string + CustomBaseURL string + CustomModel string + CustomAPIKey string + EnvVars map[string]string } // NewWizardContext creates an initialized WizardContext. @@ -99,6 +100,11 @@ func (w WizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: w.width = msg.Width w.height = msg.Height + if w.current < len(w.steps) { + updated, cmd := w.steps[w.current].Update(msg) + w.steps[w.current] = updated + return w, cmd + } return w, nil case tea.KeyMsg: diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index a4410b2..4230570 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -358,6 +358,10 @@ func (r *Runner) Run(ctx context.Context) error { mc := coreruntime.ResolveModelConfig(r.cfg.Config, envVars, r.cfg.ProviderOverride) if mc != nil { r.modelConfig = mc + // Export org ID for skill scripts + if mc.Client.OrgID != "" { + _ = os.Setenv("OPENAI_ORG_ID", mc.Client.OrgID) + } llmClient, llmErr := r.buildLLMClient(mc) if llmErr != nil { r.logger.Warn("failed to create LLM client, using stub", map[string]any{"error": llmErr.Error()}) @@ -1304,6 +1308,9 @@ func (r *Runner) registerAuditHooks(hooks *coreruntime.HookRegistry, auditLogger if hctx.Response != nil && hctx.Response.Usage.TotalTokens > 0 { fields["tokens"] = hctx.Response.Usage.TotalTokens } + if r.modelConfig != nil && r.modelConfig.Client.OrgID != "" { + fields["organization_id"] = r.modelConfig.Client.OrgID + } auditLogger.Emit(coreruntime.AuditEvent{ Event: coreruntime.AuditLLMCall, CorrelationID: hctx.CorrelationID, @@ -1993,6 +2000,7 @@ func (r *Runner) resolveEmbedder(mc *coreruntime.ModelConfig) llm.Embedder { cfg := providers.OpenAIEmbedderConfig{ APIKey: mc.Client.APIKey, + OrgID: mc.Client.OrgID, Model: r.cfg.Config.Memory.EmbeddingModel, } @@ -2002,6 +2010,7 @@ func (r *Runner) resolveEmbedder(mc *coreruntime.ModelConfig) llm.Embedder { if fb.Provider == embProvider { cfg.APIKey = fb.Client.APIKey cfg.BaseURL = fb.Client.BaseURL + cfg.OrgID = fb.Client.OrgID break } } diff --git a/forge-cli/templates/init/env.example.tmpl b/forge-cli/templates/init/env.example.tmpl index c804947..2110ea0 100644 --- a/forge-cli/templates/init/env.example.tmpl +++ b/forge-cli/templates/init/env.example.tmpl @@ -1,6 +1,7 @@ # {{.Name}} Environment Variables {{- if eq .ModelProvider "openai"}} OPENAI_API_KEY=your-api-key-here +# OPENAI_ORG_ID=org-your-organization-id (enterprise only) {{- else if eq .ModelProvider "anthropic"}} ANTHROPIC_API_KEY=your-api-key-here {{- else if eq .ModelProvider "gemini"}} diff --git a/forge-cli/templates/init/forge.yaml.tmpl b/forge-cli/templates/init/forge.yaml.tmpl index ec6113f..9a874be 100644 --- a/forge-cli/templates/init/forge.yaml.tmpl +++ b/forge-cli/templates/init/forge.yaml.tmpl @@ -9,6 +9,9 @@ model: provider: {{.ModelProvider}} name: {{.ModelName}} version: "latest" +{{- if .OrganizationID}} + organization_id: {{.OrganizationID}} +{{- end}} {{- if .Fallbacks}} fallbacks: {{- range .Fallbacks}} diff --git a/forge-cli/tools/exec.go b/forge-cli/tools/exec.go index ddc3431..7ebbc37 100644 --- a/forge-cli/tools/exec.go +++ b/forge-cli/tools/exec.go @@ -62,6 +62,9 @@ func (e *SkillCommandExecutor) Run(ctx context.Context, command string, args []s env = append(env, name+"="+val) } } + if orgID := os.Getenv("OPENAI_ORG_ID"); orgID != "" { + env = append(env, "OPENAI_ORG_ID="+orgID) + } if e.ProxyURL != "" { env = append(env, "HTTP_PROXY="+e.ProxyURL, diff --git a/forge-cli/tools/exec_test.go b/forge-cli/tools/exec_test.go new file mode 100644 index 0000000..e4516fc --- /dev/null +++ b/forge-cli/tools/exec_test.go @@ -0,0 +1,41 @@ +package tools + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestSkillCommandExecutor_OrgIDInjection(t *testing.T) { + // Set the env var + t.Setenv("OPENAI_ORG_ID", "org-test-skill-123") + + e := &SkillCommandExecutor{} + + // Run a command that prints environment variables + out, err := e.Run(context.Background(), "env", nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(out, "OPENAI_ORG_ID=org-test-skill-123") { + t.Errorf("expected OPENAI_ORG_ID in env output, got: %s", out) + } +} + +func TestSkillCommandExecutor_NoOrgIDWhenUnset(t *testing.T) { + // Ensure the env var is NOT set + os.Unsetenv("OPENAI_ORG_ID") //nolint:errcheck + + e := &SkillCommandExecutor{} + + out, err := e.Run(context.Background(), "env", nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(out, "OPENAI_ORG_ID") { + t.Errorf("expected no OPENAI_ORG_ID in env output, got: %s", out) + } +} diff --git a/forge-core/llm/providers/openai_embedder.go b/forge-core/llm/providers/openai_embedder.go index dd3eadb..15a5b37 100644 --- a/forge-core/llm/providers/openai_embedder.go +++ b/forge-core/llm/providers/openai_embedder.go @@ -22,6 +22,7 @@ const ( // OpenAIEmbedder implements llm.Embedder using the OpenAI Embeddings API. type OpenAIEmbedder struct { apiKey string + orgID string baseURL string model string dims int @@ -31,6 +32,7 @@ type OpenAIEmbedder struct { // OpenAIEmbedderConfig configures the OpenAI embedder. type OpenAIEmbedderConfig struct { APIKey string + OrgID string BaseURL string Model string Dims int @@ -52,6 +54,7 @@ func NewOpenAIEmbedder(cfg OpenAIEmbedderConfig) *OpenAIEmbedder { } return &OpenAIEmbedder{ apiKey: cfg.APIKey, + orgID: cfg.OrgID, baseURL: strings.TrimRight(baseURL, "/"), model: model, dims: dims, @@ -89,6 +92,9 @@ func (e *OpenAIEmbedder) Embed(ctx context.Context, req *llm.EmbeddingRequest) ( if e.apiKey != "" { httpReq.Header.Set("Authorization", "Bearer "+e.apiKey) } + if e.orgID != "" { + httpReq.Header.Set("OpenAI-Organization", e.orgID) + } resp, err := e.client.Do(httpReq) if err != nil { diff --git a/forge-core/llm/providers/openai_embedder_test.go b/forge-core/llm/providers/openai_embedder_test.go new file mode 100644 index 0000000..e0bcc38 --- /dev/null +++ b/forge-core/llm/providers/openai_embedder_test.go @@ -0,0 +1,65 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/initializ/forge/forge-core/llm" +) + +func TestOpenAIEmbedder_OrgIDHeader(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("OpenAI-Organization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":1,"total_tokens":1}}`)) + })) + defer srv.Close() + + embedder := NewOpenAIEmbedder(OpenAIEmbedderConfig{ + APIKey: "sk-test", + OrgID: "org-embed-456", + BaseURL: srv.URL, + Model: "text-embedding-3-small", + Dims: 3, + }) + + _, err := embedder.Embed(context.Background(), &llm.EmbeddingRequest{ + Texts: []string{"hello"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotHeader != "org-embed-456" { + t.Errorf("expected OpenAI-Organization header org-embed-456, got %q", gotHeader) + } +} + +func TestOpenAIEmbedder_NoOrgIDHeader(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("OpenAI-Organization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":1,"total_tokens":1}}`)) + })) + defer srv.Close() + + embedder := NewOpenAIEmbedder(OpenAIEmbedderConfig{ + APIKey: "sk-test", + BaseURL: srv.URL, + Model: "text-embedding-3-small", + Dims: 3, + }) + + _, err := embedder.Embed(context.Background(), &llm.EmbeddingRequest{ + Texts: []string{"hello"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotHeader != "" { + t.Errorf("expected no OpenAI-Organization header, got %q", gotHeader) + } +} diff --git a/forge-core/llm/providers/openai_test.go b/forge-core/llm/providers/openai_test.go new file mode 100644 index 0000000..2c68b8b --- /dev/null +++ b/forge-core/llm/providers/openai_test.go @@ -0,0 +1,63 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/initializ/forge/forge-core/llm" +) + +func TestOpenAIClient_OrgIDHeader(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("OpenAI-Organization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + })) + defer srv.Close() + + client := NewOpenAIClient(llm.ClientConfig{ + APIKey: "sk-test", + OrgID: "org-test-123", + Model: "gpt-4o", + BaseURL: srv.URL, + }) + + _, err := client.Chat(context.Background(), &llm.ChatRequest{ + Messages: []llm.ChatMessage{{Role: llm.RoleUser, Content: "hello"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotHeader != "org-test-123" { + t.Errorf("expected OpenAI-Organization header org-test-123, got %q", gotHeader) + } +} + +func TestOpenAIClient_NoOrgIDHeader(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("OpenAI-Organization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + })) + defer srv.Close() + + client := NewOpenAIClient(llm.ClientConfig{ + APIKey: "sk-test", + Model: "gpt-4o", + BaseURL: srv.URL, + }) + + _, err := client.Chat(context.Background(), &llm.ChatRequest{ + Messages: []llm.ChatMessage{{Role: llm.RoleUser, Content: "hello"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotHeader != "" { + t.Errorf("expected no OpenAI-Organization header, got %q", gotHeader) + } +} diff --git a/forge-core/llm/providers/responses.go b/forge-core/llm/providers/responses.go index 1045ee4..e957a91 100644 --- a/forge-core/llm/providers/responses.go +++ b/forge-core/llm/providers/responses.go @@ -19,6 +19,7 @@ import ( // endpoint (chatgpt.com/backend-api) rather than the Chat Completions API. type ResponsesClient struct { apiKey string + orgID string baseURL string model string client *http.Client @@ -37,6 +38,7 @@ func NewResponsesClient(cfg llm.ClientConfig) *ResponsesClient { } return &ResponsesClient{ apiKey: cfg.APIKey, + orgID: cfg.OrgID, baseURL: strings.TrimRight(baseURL, "/"), model: cfg.Model, client: &http.Client{Timeout: timeout}, @@ -140,6 +142,9 @@ func (c *ResponsesClient) setHeaders(req *http.Request) { if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) } + if c.orgID != "" { + req.Header.Set("OpenAI-Organization", c.orgID) + } } // --- Request types --- diff --git a/forge-core/llm/providers/responses_test.go b/forge-core/llm/providers/responses_test.go new file mode 100644 index 0000000..03d9318 --- /dev/null +++ b/forge-core/llm/providers/responses_test.go @@ -0,0 +1,65 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/initializ/forge/forge-core/llm" +) + +func TestResponsesClient_OrgIDHeader(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("OpenAI-Organization") + w.Header().Set("Content-Type", "text/event-stream") + // Minimal streaming response + _, _ = w.Write([]byte("event: response.output_text.delta\ndata: {\"output_index\":0,\"content_index\":0,\"delta\":\"hi\"}\n\n")) + _, _ = w.Write([]byte("event: response.completed\ndata: {\"response\":{\"id\":\"resp-1\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) + })) + defer srv.Close() + + client := NewResponsesClient(llm.ClientConfig{ + APIKey: "sk-test", + OrgID: "org-resp-789", + Model: "gpt-4o", + BaseURL: srv.URL, + }) + + _, err := client.Chat(context.Background(), &llm.ChatRequest{ + Messages: []llm.ChatMessage{{Role: llm.RoleUser, Content: "hello"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotHeader != "org-resp-789" { + t.Errorf("expected OpenAI-Organization header org-resp-789, got %q", gotHeader) + } +} + +func TestResponsesClient_NoOrgIDHeader(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("OpenAI-Organization") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: response.completed\ndata: {\"response\":{\"id\":\"resp-1\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) + })) + defer srv.Close() + + client := NewResponsesClient(llm.ClientConfig{ + APIKey: "sk-test", + Model: "gpt-4o", + BaseURL: srv.URL, + }) + + _, err := client.Chat(context.Background(), &llm.ChatRequest{ + Messages: []llm.ChatMessage{{Role: llm.RoleUser, Content: "hello"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotHeader != "" { + t.Errorf("expected no OpenAI-Organization header, got %q", gotHeader) + } +} diff --git a/forge-core/runtime/config.go b/forge-core/runtime/config.go index 089b07d..9b326aa 100644 --- a/forge-core/runtime/config.go +++ b/forge-core/runtime/config.go @@ -48,6 +48,14 @@ func ResolveModelConfig(cfg *types.ForgeConfig, envVars map[string]string, provi // Resolve API key based on provider resolveAPIKey(mc, envVars) + // Wire organization ID for OpenAI + if mc.Provider == "openai" && cfg.Model.OrganizationID != "" { + mc.Client.OrgID = cfg.Model.OrganizationID + } + if orgID := envVars["OPENAI_ORG_ID"]; orgID != "" && mc.Provider == "openai" { + mc.Client.OrgID = orgID + } + // CLI override is highest priority if providerOverride != "" { mc.Provider = providerOverride @@ -119,7 +127,7 @@ func resolveFallbacks(cfg *types.ForgeConfig, envVars map[string]string, primary seen := map[string]bool{primaryProvider: true} var fallbacks []FallbackModelConfig - addFallback := func(provider, model string) { + addFallback := func(provider, model, orgID string) { if seen[provider] { return } @@ -143,12 +151,23 @@ func resolveFallbacks(cfg *types.ForgeConfig, envVars map[string]string, primary } // Apply base URL overrides fc.Client.BaseURL = resolveFallbackBaseURL(provider, envVars) + // Wire organization ID for OpenAI fallbacks + if provider == "openai" { + resolvedOrgID := orgID + if resolvedOrgID == "" { + resolvedOrgID = cfg.Model.OrganizationID + } + if envOrgID := envVars["OPENAI_ORG_ID"]; envOrgID != "" { + resolvedOrgID = envOrgID + } + fc.Client.OrgID = resolvedOrgID + } fallbacks = append(fallbacks, fc) } // Source 1: forge.yaml model.fallbacks for _, fb := range cfg.Model.Fallbacks { - addFallback(fb.Provider, fb.Name) + addFallback(fb.Provider, fb.Name, fb.OrganizationID) } // Source 2: FORGE_MODEL_FALLBACKS env var @@ -159,7 +178,7 @@ func resolveFallbacks(cfg *types.ForgeConfig, envVars map[string]string, primary continue } provider, model, _ := strings.Cut(entry, ":") - addFallback(provider, model) + addFallback(provider, model, "") } } @@ -171,7 +190,7 @@ func resolveFallbacks(cfg *types.ForgeConfig, envVars map[string]string, primary } for provider, keyName := range providerKeys { if envVars[keyName] != "" { - addFallback(provider, "") + addFallback(provider, "", "") } } diff --git a/forge-core/runtime/config_test.go b/forge-core/runtime/config_test.go index ca9aede..6b97e80 100644 --- a/forge-core/runtime/config_test.go +++ b/forge-core/runtime/config_test.go @@ -176,6 +176,128 @@ func TestResolveModelConfig_NoFallbacksWhenSingleProvider(t *testing.T) { } } +func TestResolveModelConfig_OrgIDFromYAML(t *testing.T) { + cfg := &types.ForgeConfig{ + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-5.2-2025-12-11", + OrganizationID: "org-yaml-123", + }, + } + envVars := map[string]string{ + "OPENAI_API_KEY": "sk-test", + } + + mc := ResolveModelConfig(cfg, envVars, "") + if mc == nil { + t.Fatal("expected non-nil ModelConfig") + } + if mc.Client.OrgID != "org-yaml-123" { + t.Errorf("expected OrgID org-yaml-123, got %s", mc.Client.OrgID) + } +} + +func TestResolveModelConfig_OrgIDEnvOverridesYAML(t *testing.T) { + cfg := &types.ForgeConfig{ + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-5.2-2025-12-11", + OrganizationID: "org-yaml-123", + }, + } + envVars := map[string]string{ + "OPENAI_API_KEY": "sk-test", + "OPENAI_ORG_ID": "org-env-456", + } + + mc := ResolveModelConfig(cfg, envVars, "") + if mc == nil { + t.Fatal("expected non-nil ModelConfig") + } + if mc.Client.OrgID != "org-env-456" { + t.Errorf("expected OrgID org-env-456, got %s", mc.Client.OrgID) + } +} + +func TestResolveModelConfig_OrgIDNotSetForNonOpenAI(t *testing.T) { + cfg := &types.ForgeConfig{ + Model: types.ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-20250514", + }, + } + envVars := map[string]string{ + "ANTHROPIC_API_KEY": "sk-ant-test", + "OPENAI_ORG_ID": "org-env-456", + } + + mc := ResolveModelConfig(cfg, envVars, "") + if mc == nil { + t.Fatal("expected non-nil ModelConfig") + } + if mc.Client.OrgID != "" { + t.Errorf("expected empty OrgID for anthropic, got %s", mc.Client.OrgID) + } +} + +func TestResolveModelConfig_FallbackOrgIDInheritance(t *testing.T) { + cfg := &types.ForgeConfig{ + Model: types.ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-20250514", + OrganizationID: "org-primary-123", + Fallbacks: []types.ModelFallback{ + {Provider: "openai", Name: "gpt-4o"}, + }, + }, + } + envVars := map[string]string{ + "ANTHROPIC_API_KEY": "sk-ant-test", + "OPENAI_API_KEY": "sk-openai-test", + } + + mc := ResolveModelConfig(cfg, envVars, "") + if mc == nil { + t.Fatal("expected non-nil ModelConfig") + } + if len(mc.Fallbacks) != 1 { + t.Fatalf("expected 1 fallback, got %d", len(mc.Fallbacks)) + } + // Fallback should inherit primary org ID + if mc.Fallbacks[0].Client.OrgID != "org-primary-123" { + t.Errorf("expected fallback OrgID org-primary-123, got %s", mc.Fallbacks[0].Client.OrgID) + } +} + +func TestResolveModelConfig_FallbackOrgIDOverride(t *testing.T) { + cfg := &types.ForgeConfig{ + Model: types.ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-20250514", + OrganizationID: "org-primary-123", + Fallbacks: []types.ModelFallback{ + {Provider: "openai", Name: "gpt-4o", OrganizationID: "org-fallback-789"}, + }, + }, + } + envVars := map[string]string{ + "ANTHROPIC_API_KEY": "sk-ant-test", + "OPENAI_API_KEY": "sk-openai-test", + } + + mc := ResolveModelConfig(cfg, envVars, "") + if mc == nil { + t.Fatal("expected non-nil ModelConfig") + } + if len(mc.Fallbacks) != 1 { + t.Fatalf("expected 1 fallback, got %d", len(mc.Fallbacks)) + } + // Fallback-specific org ID should take precedence over primary + if mc.Fallbacks[0].Client.OrgID != "org-fallback-789" { + t.Errorf("expected fallback OrgID org-fallback-789, got %s", mc.Fallbacks[0].Client.OrgID) + } +} + func TestDefaultModelForProvider(t *testing.T) { tests := []struct { provider string diff --git a/forge-core/types/config.go b/forge-core/types/config.go index 61b794c..a5b5695 100644 --- a/forge-core/types/config.go +++ b/forge-core/types/config.go @@ -73,16 +73,18 @@ type SkillsRef struct { // ModelRef identifies the model an agent uses. type ModelRef struct { - Provider string `yaml:"provider"` - Name string `yaml:"name"` - Version string `yaml:"version,omitempty"` - Fallbacks []ModelFallback `yaml:"fallbacks,omitempty"` + Provider string `yaml:"provider"` + Name string `yaml:"name"` + Version string `yaml:"version,omitempty"` + OrganizationID string `yaml:"organization_id,omitempty"` + Fallbacks []ModelFallback `yaml:"fallbacks,omitempty"` } // ModelFallback identifies an alternative LLM provider for fallback. type ModelFallback struct { - Provider string `yaml:"provider"` - Name string `yaml:"name,omitempty"` + Provider string `yaml:"provider"` + Name string `yaml:"name,omitempty"` + OrganizationID string `yaml:"organization_id,omitempty"` } // ToolRef is a lightweight reference to a tool in forge.yaml. diff --git a/forge-core/validate/forge_config.go b/forge-core/validate/forge_config.go index aeef935..7ff5da5 100644 --- a/forge-core/validate/forge_config.go +++ b/forge-core/validate/forge_config.go @@ -68,6 +68,10 @@ func ValidateForgeConfig(cfg *types.ForgeConfig) *ValidationResult { r.Warnings = append(r.Warnings, "model.provider is set but model.name is empty") } + if cfg.Model.OrganizationID != "" && cfg.Model.Provider != "" && cfg.Model.Provider != "openai" { + r.Warnings = append(r.Warnings, fmt.Sprintf("model.organization_id is set but provider is %q (only used by openai)", cfg.Model.Provider)) + } + if cfg.Framework != "" && !knownFrameworks[cfg.Framework] { r.Warnings = append(r.Warnings, fmt.Sprintf("unknown framework %q (known: forge, crewai, langchain)", cfg.Framework)) } diff --git a/forge-core/validate/forge_config_test.go b/forge-core/validate/forge_config_test.go index 2a962a5..6a92158 100644 --- a/forge-core/validate/forge_config_test.go +++ b/forge-core/validate/forge_config_test.go @@ -103,3 +103,38 @@ func TestValidateForgeConfig_UnknownFramework(t *testing.T) { t.Fatalf("expected 1 warning, got %d: %v", len(r.Warnings), r.Warnings) } } + +func TestValidateForgeConfig_OrgIDOnNonOpenAI(t *testing.T) { + cfg := validConfig() + cfg.Model.Provider = "anthropic" + cfg.Model.OrganizationID = "org-test-123" + r := ValidateForgeConfig(cfg) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + found := false + for _, w := range r.Warnings { + if len(w) > 0 && w[0:5] == "model" { + found = true + } + } + if !found { + t.Error("expected warning about organization_id on non-openai provider") + } +} + +func TestValidateForgeConfig_OrgIDOnOpenAI(t *testing.T) { + cfg := validConfig() + cfg.Model.Provider = "openai" + cfg.Model.OrganizationID = "org-test-123" + r := ValidateForgeConfig(cfg) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + // Should NOT produce a warning for openai + for _, w := range r.Warnings { + if len(w) > 18 && w[:18] == "model.organization" { + t.Errorf("unexpected warning for openai: %s", w) + } + } +} diff --git a/forge-ui/handlers_create.go b/forge-ui/handlers_create.go index 24e532c..62fcd60 100644 --- a/forge-ui/handlers_create.go +++ b/forge-ui/handlers_create.go @@ -29,9 +29,10 @@ func (s *UIServer) handleGetWizardMeta(w http.ResponseWriter, _ *http.Request) { // Per-provider model lists meta.ProviderModels = map[string]ProviderModels{ "openai": { - Default: "gpt-5.2-2025-12-11", - NeedsKey: true, - HasOAuth: true, + Default: "gpt-5.2-2025-12-11", + NeedsKey: true, + HasOAuth: true, + SupportsOrgID: true, APIKey: []ModelOption{ {DisplayName: "GPT 5.2", ModelID: "gpt-5.2-2025-12-11"}, {DisplayName: "GPT 5 Mini", ModelID: "gpt-5-mini-2025-08-07"}, diff --git a/forge-ui/static/dist/app.js b/forge-ui/static/dist/app.js index 9db77c3..f14979f 100644 --- a/forge-ui/static/dist/app.js +++ b/forge-ui/static/dist/app.js @@ -1056,6 +1056,7 @@ function CreatePage() { const [form, setForm] = useState({ name: '', framework: 'forge', model_provider: '', model_name: '', api_key: '', auth_method: 'apikey', // "apikey" or "oauth" + organization_id: '', // OpenAI enterprise org ID web_search_provider: '', // "tavily" or "perplexity" channels: [], builtin_tools: [], skills: [], fallbacks: [], // [{provider, api_key}] @@ -1360,6 +1361,19 @@ function CreatePage() { `} + + ${providerMeta?.supports_org_id && form.auth_method === 'apikey' && html` +
+ + updateForm('organization_id', e.target.value)} /> +
+ Stored in .env as OPENAI_ORG_ID. Leave empty if not using an organization. +
+
+ `} `; } diff --git a/forge-ui/types.go b/forge-ui/types.go index 04aafaf..2aed040 100644 --- a/forge-ui/types.go +++ b/forge-ui/types.go @@ -84,6 +84,7 @@ type AgentCreateOptions struct { ModelName string `json:"model_name,omitempty"` APIKey string `json:"api_key,omitempty"` AuthMethod string `json:"auth_method,omitempty"` // "apikey" or "oauth" + OrganizationID string `json:"organization_id,omitempty"` Channels []string `json:"channels,omitempty"` BuiltinTools []string `json:"builtin_tools,omitempty"` Skills []string `json:"skills,omitempty"` @@ -147,13 +148,14 @@ type ModelOption struct { // ProviderModels holds model lists for a specific provider. type ProviderModels struct { - Default string `json:"default"` - APIKey []ModelOption `json:"api_key,omitempty"` - OAuth []ModelOption `json:"oauth,omitempty"` - HasOAuth bool `json:"has_oauth,omitempty"` - NeedsKey bool `json:"needs_key"` - IsCustom bool `json:"is_custom,omitempty"` - BaseURLEnv string `json:"base_url_env,omitempty"` // e.g. "MODEL_BASE_URL" + Default string `json:"default"` + APIKey []ModelOption `json:"api_key,omitempty"` + OAuth []ModelOption `json:"oauth,omitempty"` + HasOAuth bool `json:"has_oauth,omitempty"` + NeedsKey bool `json:"needs_key"` + IsCustom bool `json:"is_custom,omitempty"` + BaseURLEnv string `json:"base_url_env,omitempty"` // e.g. "MODEL_BASE_URL" + SupportsOrgID bool `json:"supports_org_id,omitempty"` } // WebSearchProviderOption describes a web search provider.