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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## Unreleased

### Fixed

- **`forge init` Custom provider now produces a runnable agent (issue #83).**
Picking the **Custom** provider in `forge init` (or the Web UI wizard)
previously wrote `provider: custom` to `forge.yaml` plus
`MODEL_BASE_URL` / `MODEL_API_KEY` env vars, neither of which the runtime
understood — agents fell back to `StubExecutor` and every task failed
with `agent execution not configured for framework "forge"`. Scaffold
now normalizes Custom → `provider: openai` + `OPENAI_BASE_URL` /
`OPENAI_API_KEY`, matching the OpenAI-compatible code path the runtime
resolver already supports. Affects both TUI and Web UI flows.
- **OAuth-credentials path no longer silently overrides
`OPENAI_BASE_URL` (issue #83).** When the runtime or skill builder
found stored ChatGPT OAuth credentials AND no `OPENAI_API_KEY`, it
ignored an explicitly-set `OPENAI_BASE_URL` and routed traffic to
`chatgpt.com/backend-api/codex` — manifesting as a 400 from ChatGPT
rejecting the operator's model name. Both `forge run` and `forge ui`
now refuse this combination with a clear error explaining what to set.

### Migration

- If you have `provider: custom` in a checked-in `forge.yaml` from an
earlier `forge init` run, change it to `provider: openai` and rename
the `.env` keys from `MODEL_BASE_URL` / `MODEL_API_KEY` to
`OPENAI_BASE_URL` / `OPENAI_API_KEY`. No new `forge init` is required.

## v0.12.0 — Phase 1: MCP integration (HTTP transport) — in progress

### Added
Expand Down
67 changes: 56 additions & 11 deletions forge-cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,11 @@ func collectInteractive(opts *initOptions) error {
opts.EnvVars[k] = v
}

// Custom provider env vars
// Custom provider env vars. The wizard collects a base URL + API key
// for the OpenAI-compatible endpoint. Write under the legacy
// MODEL_* names here; normalizeCustomProvider rewrites them to
// OPENAI_BASE_URL / OPENAI_API_KEY at scaffold time so both this
// path and the Web UI POST (which also uses MODEL_*) converge.
if ctx.CustomBaseURL != "" {
opts.EnvVars["MODEL_BASE_URL"] = ctx.CustomBaseURL
}
Expand Down Expand Up @@ -513,6 +517,48 @@ func storeProviderEnvVar(opts *initOptions) {
}
}

// normalizeCustomProvider rewrites the wizard's "Custom" provider option
// to the OpenAI-compatible shape that the runtime resolver actually
// consumes: provider=openai + OPENAI_BASE_URL + OPENAI_API_KEY.
//
// Background (issue #83): the wizard's Custom path is meant for OpenAI-
// compatible endpoints (OpenRouter, litellm, vLLM, self-hosted Kimi/
// Llama, etc.). Before normalization it wrote provider=custom to
// forge.yaml and MODEL_BASE_URL / MODEL_API_KEY to .env — but the LLM
// client factory has no "custom" case (it returns an unknown-provider
// error) and ResolveModelConfig never reads MODEL_BASE_URL or
// MODEL_API_KEY. The runtime fell back to StubExecutor and every task
// failed with "agent execution not configured for framework forge".
//
// The fix is to normalize at the single scaffold entry point so both
// the TUI wizard and the Web UI Custom flow produce the same shape.
func normalizeCustomProvider(opts *initOptions) {
if opts.ModelProvider != "custom" {
return
}
opts.ModelProvider = "openai"
// Migrate legacy MODEL_BASE_URL / MODEL_API_KEY (TUI wizard,
// older Web UI revs) to the OPENAI_* names the runtime reads.
if v := opts.EnvVars["MODEL_BASE_URL"]; v != "" {
opts.EnvVars["OPENAI_BASE_URL"] = v
delete(opts.EnvVars, "MODEL_BASE_URL")
}
if v := opts.EnvVars["MODEL_API_KEY"]; v != "" {
opts.EnvVars["OPENAI_API_KEY"] = v
delete(opts.EnvVars, "MODEL_API_KEY")
if opts.APIKey == "" {
opts.APIKey = v
}
}
// storeProviderEnvVar runs before scaffold and short-circuits when
// provider="custom"; fold in any opts.APIKey set by flag or POST
// so the .env emits OPENAI_API_KEY consistently with the path
// taken by every other openai-shaped configuration.
if opts.APIKey != "" && opts.EnvVars["OPENAI_API_KEY"] == "" {
opts.EnvVars["OPENAI_API_KEY"] = opts.APIKey
}
}

// checkSkillRequirements checks binary and env requirements for selected skills.
func checkSkillRequirements(opts *initOptions) {
chkReg, chkErr := local.NewEmbeddedRegistry()
Expand Down Expand Up @@ -594,6 +640,8 @@ func parseSkillsFile(path string) ([]toolEntry, error) {
}

func scaffold(opts *initOptions) error {
normalizeCustomProvider(opts)

dir := filepath.Join(".", opts.AgentID)

// Check if directory already exists
Expand Down Expand Up @@ -1124,6 +1172,13 @@ func buildEnvVars(opts *initOptions) []envVarEntry {
val = "your-api-key-here"
}
vars = append(vars, envVarEntry{Key: "OPENAI_API_KEY", Value: val, Comment: "OpenAI API key"})
// OPENAI_BASE_URL is set when the wizard's Custom provider
// option is used against an OpenAI-compatible endpoint
// (OpenRouter, vLLM, litellm, etc.) and normalizeCustomProvider
// has rewritten provider=custom to provider=openai. (Issue #83.)
if baseURL := opts.EnvVars["OPENAI_BASE_URL"]; baseURL != "" {
vars = append(vars, envVarEntry{Key: "OPENAI_BASE_URL", Value: baseURL, Comment: "OpenAI-compatible endpoint base URL"})
}
if orgID := opts.OrganizationID; orgID != "" {
vars = append(vars, envVarEntry{Key: "OPENAI_ORG_ID", Value: orgID, Comment: "OpenAI organization ID (enterprise)"})
}
Expand All @@ -1141,16 +1196,6 @@ func buildEnvVars(opts *initOptions) []envVarEntry {
vars = append(vars, envVarEntry{Key: "GEMINI_API_KEY", Value: val, Comment: "Gemini API key"})
case "ollama":
vars = append(vars, envVarEntry{Key: "OLLAMA_HOST", Value: "http://localhost:11434", Comment: "Ollama host"})
case "custom":
baseURL := opts.EnvVars["MODEL_BASE_URL"]
if baseURL != "" {
vars = append(vars, envVarEntry{Key: "MODEL_BASE_URL", Value: baseURL, Comment: "Custom model endpoint URL"})
}
apiKeyVal := opts.EnvVars["MODEL_API_KEY"]
if apiKeyVal == "" {
apiKeyVal = "your-api-key-here"
}
vars = append(vars, envVarEntry{Key: "MODEL_API_KEY", Value: apiKeyVal, Comment: "Model provider API key"})
}

// Web search provider key if web_search selected
Expand Down
228 changes: 228 additions & 0 deletions forge-cli/cmd/init_custom_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

// Regression tests for issue #83 — Custom-provider wizard path produces
// forge.yaml + .env that the runtime can actually consume.

func TestNormalizeCustomProvider_RewritesLegacyEnvVars(t *testing.T) {
opts := &initOptions{
ModelProvider: "custom",
EnvVars: map[string]string{
"MODEL_BASE_URL": "https://endpoint.example.com/v1",
"MODEL_API_KEY": "sk-test",
"UNRELATED": "keep-me",
},
}
normalizeCustomProvider(opts)

if opts.ModelProvider != "openai" {
t.Errorf("ModelProvider = %q, want %q", opts.ModelProvider, "openai")
}
if got := opts.EnvVars["OPENAI_BASE_URL"]; got != "https://endpoint.example.com/v1" {
t.Errorf("OPENAI_BASE_URL = %q, want endpoint URL", got)
}
if got := opts.EnvVars["OPENAI_API_KEY"]; got != "sk-test" {
t.Errorf("OPENAI_API_KEY = %q, want sk-test", got)
}
if _, present := opts.EnvVars["MODEL_BASE_URL"]; present {
t.Errorf("MODEL_BASE_URL should be deleted after normalization")
}
if _, present := opts.EnvVars["MODEL_API_KEY"]; present {
t.Errorf("MODEL_API_KEY should be deleted after normalization")
}
if got := opts.EnvVars["UNRELATED"]; got != "keep-me" {
t.Errorf("UNRELATED key should be preserved, got %q", got)
}
if opts.APIKey != "sk-test" {
t.Errorf("opts.APIKey should be filled from MODEL_API_KEY, got %q", opts.APIKey)
}
}

func TestNormalizeCustomProvider_NoOpForOtherProviders(t *testing.T) {
cases := []string{"openai", "anthropic", "gemini", "ollama"}
for _, p := range cases {
t.Run(p, func(t *testing.T) {
opts := &initOptions{
ModelProvider: p,
EnvVars: map[string]string{
"MODEL_BASE_URL": "https://should-not-be-touched",
"MODEL_API_KEY": "should-not-be-touched",
},
}
normalizeCustomProvider(opts)
if opts.ModelProvider != p {
t.Errorf("ModelProvider mutated from %q to %q", p, opts.ModelProvider)
}
if got := opts.EnvVars["MODEL_BASE_URL"]; got != "https://should-not-be-touched" {
t.Errorf("MODEL_BASE_URL should not be touched for provider=%s, got %q", p, got)
}
})
}
}

func TestNormalizeCustomProvider_PreExistingOpenAIVarsPreserved(t *testing.T) {
// Newer Web UI revs write OPENAI_BASE_URL directly. The normalizer
// should accept that and not clobber it.
opts := &initOptions{
ModelProvider: "custom",
EnvVars: map[string]string{
"OPENAI_BASE_URL": "https://from-webui",
"OPENAI_API_KEY": "sk-from-webui",
},
}
normalizeCustomProvider(opts)

if opts.ModelProvider != "openai" {
t.Errorf("ModelProvider = %q, want openai", opts.ModelProvider)
}
if got := opts.EnvVars["OPENAI_BASE_URL"]; got != "https://from-webui" {
t.Errorf("OPENAI_BASE_URL clobbered, got %q", got)
}
if got := opts.EnvVars["OPENAI_API_KEY"]; got != "sk-from-webui" {
t.Errorf("OPENAI_API_KEY clobbered, got %q", got)
}
}

func TestNormalizeCustomProvider_APIKeyFallsBackToOptsField(t *testing.T) {
// Non-interactive --api-key flag path: APIKey is set on opts directly
// (storeProviderEnvVar would skip the openai branch when provider was
// still "custom"). After normalization, the API key must reach OPENAI_API_KEY.
opts := &initOptions{
ModelProvider: "custom",
APIKey: "sk-from-flag",
EnvVars: map[string]string{"MODEL_BASE_URL": "https://endpoint"},
}
normalizeCustomProvider(opts)

if opts.ModelProvider != "openai" {
t.Errorf("ModelProvider = %q, want openai", opts.ModelProvider)
}
if got := opts.EnvVars["OPENAI_API_KEY"]; got != "sk-from-flag" {
t.Errorf("OPENAI_API_KEY = %q, want sk-from-flag", got)
}
if got := opts.EnvVars["OPENAI_BASE_URL"]; got != "https://endpoint" {
t.Errorf("OPENAI_BASE_URL = %q, want endpoint", got)
}
}

// End-to-end: scaffold with the Custom provider shape produces a
// forge.yaml whose model.provider is "openai" and a .env containing
// OPENAI_BASE_URL + OPENAI_API_KEY (not MODEL_*).
func TestScaffold_CustomProviderProducesOpenAIShape(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

opts := &initOptions{
Name: "custom-shape",
AgentID: "custom-shape",
Framework: "forge",
ModelProvider: "custom",
CustomModel: "moonshotai/Kimi-K2.6",
APIKey: "sk-endpoint",
EnvVars: map[string]string{
"MODEL_BASE_URL": "https://openrouter-ish.example.com/v1",
"MODEL_API_KEY": "sk-endpoint",
},
NonInteractive: true,
}

if err := scaffold(opts); err != nil {
t.Fatalf("scaffold: %v", err)
}

// forge.yaml: provider must be "openai" (not "custom"), model.name preserved.
cfgPath := filepath.Join("custom-shape", "forge.yaml")
cfgRaw, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("reading forge.yaml: %v", err)
}
var cfg struct {
Model struct {
Provider string `yaml:"provider"`
Name string `yaml:"name"`
} `yaml:"model"`
}
if err := yaml.Unmarshal(cfgRaw, &cfg); err != nil {
t.Fatalf("parsing forge.yaml: %v\n%s", err, cfgRaw)
}
if cfg.Model.Provider != "openai" {
t.Errorf("forge.yaml model.provider = %q, want %q\n--- forge.yaml ---\n%s",
cfg.Model.Provider, "openai", cfgRaw)
}
if cfg.Model.Name != "moonshotai/Kimi-K2.6" {
t.Errorf("forge.yaml model.name = %q, want %q", cfg.Model.Name, "moonshotai/Kimi-K2.6")
}

// .env: OPENAI_BASE_URL + OPENAI_API_KEY present; legacy MODEL_* absent.
envPath := filepath.Join("custom-shape", ".env")
envContent, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("reading .env: %v", err)
}
envStr := string(envContent)
if !strings.Contains(envStr, "OPENAI_BASE_URL=https://openrouter-ish.example.com/v1") {
t.Errorf(".env missing OPENAI_BASE_URL with endpoint URL:\n%s", envStr)
}
if !strings.Contains(envStr, "OPENAI_API_KEY=sk-endpoint") {
t.Errorf(".env missing OPENAI_API_KEY=sk-endpoint:\n%s", envStr)
}
if strings.Contains(envStr, "MODEL_BASE_URL=") {
t.Errorf(".env should NOT contain MODEL_BASE_URL after normalization:\n%s", envStr)
}
if strings.Contains(envStr, "MODEL_API_KEY=") {
t.Errorf(".env should NOT contain MODEL_API_KEY after normalization:\n%s", envStr)
}
}

// Web UI parity: a POST whose ModelProvider="custom" + EnvVars already
// carry OPENAI_BASE_URL (new app.js shape) also produces the right output.
func TestScaffold_CustomProviderWebUIShape(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

opts := &initOptions{
Name: "webui-shape",
AgentID: "webui-shape",
Framework: "forge",
ModelProvider: "custom",
CustomModel: "moonshotai/Kimi-K2.6",
EnvVars: map[string]string{
"OPENAI_BASE_URL": "https://endpoint.example.com/v1",
"OPENAI_API_KEY": "sk-from-webui",
},
NonInteractive: true,
}

if err := scaffold(opts); err != nil {
t.Fatalf("scaffold: %v", err)
}

envPath := filepath.Join("webui-shape", ".env")
envContent, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("reading .env: %v", err)
}
envStr := string(envContent)
if !strings.Contains(envStr, "OPENAI_BASE_URL=https://endpoint.example.com/v1") {
t.Errorf(".env missing OPENAI_BASE_URL:\n%s", envStr)
}
if !strings.Contains(envStr, "OPENAI_API_KEY=sk-from-webui") {
t.Errorf(".env missing OPENAI_API_KEY=sk-from-webui:\n%s", envStr)
}
}
15 changes: 15 additions & 0 deletions forge-cli/cmd/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@ func runUI(cmd *cobra.Command, args []string) error {
// skip the codegen model upgrade for OAuth clients.
var client llm.Client
needsOAuth := mc.Provider == "openai" && (mc.Client.APIKey == "" || mc.Client.APIKey == "__oauth__")
// OAuth precedence guardrail (issue #83). The ChatGPT OAuth
// path's hardcoded chatgpt.com/backend-api/codex base URL is
// mutually exclusive with an operator-supplied OPENAI_BASE_URL.
// Without this guard, the skill builder would silently route
// requests to ChatGPT instead of the agent's configured
// OpenAI-compatible endpoint.
if needsOAuth && mc.Client.BaseURL != "" {
return fmt.Errorf(
"OPENAI_BASE_URL is set to %q but no OPENAI_API_KEY was provided; "+
"the OpenAI OAuth credentials path is disabled when an explicit "+
"base URL is in use (it would silently override your endpoint with "+
"chatgpt.com/backend-api/codex). Set OPENAI_API_KEY for the configured endpoint",
mc.Client.BaseURL,
)
}
if needsOAuth {
token, oauthErr := oauth.LoadCredentials(mc.Provider)
if oauthErr == nil && token != nil && token.RefreshToken != "" {
Expand Down
Loading
Loading