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` +