Summary
Picking the Custom provider during forge init (or via the Web UI wizard) writes a forge.yaml + .env pair that the runtime cannot consume. At forge run time the agent falls back to StubExecutor and every request fails with:
agent execution not configured for framework "forge"
The wizard collects the custom URL, model, and API key correctly — it just writes them to keys that the runtime never reads, while simultaneously writing a provider: value that the LLM client factory rejects outright.
Reproduction
forge init → choose Custom provider → enter base URL, model name (e.g. moonshotai/Kimi-K2.6), API key.
forge run (or forge serve).
- Send any task to the agent.
Observed forge.yaml
model:
provider: custom
name: moonshotai/Kimi-K2.6
Observed .env
MODEL_BASE_URL=https://your-endpoint.example.com/v1
MODEL_API_KEY=sk-...
Observed log
{"error":"agent execution not configured for framework \"forge\"","level":"error","msg":"execute failed", ...}
If you look just upstream you'll also find:
{"level":"warn","msg":"failed to create LLM client, using stub","error":"unknown LLM provider: \"custom\""}
Root cause
Two disconnected sides of the same feature.
1. The factory rejects provider: custom — forge-core/llm/providers/factory.go:11-27
func NewClient(provider string, cfg llm.ClientConfig) (llm.Client, error) {
switch provider {
case "openai": return NewOpenAIClient(cfg), nil
case "anthropic": return NewAnthropicClient(cfg), nil
case "gemini": ...
case "ollama": return NewOllamaClient(cfg), nil
default:
return nil, fmt.Errorf("unknown LLM provider: %q", provider)
}
}
There is no "custom" case. Runner.buildLLMClient propagates the error and the runner falls back to StubExecutor at forge-cli/runtime/runner.go:541-544.
2. The env vars the wizard writes are never read at runtime — forge-cli/cmd/init.go:389-395
if ctx.CustomBaseURL != "" {
opts.EnvVars["MODEL_BASE_URL"] = ctx.CustomBaseURL
}
if ctx.CustomAPIKey != "" {
opts.EnvVars["MODEL_API_KEY"] = ctx.CustomAPIKey
}
But ResolveModelConfig at forge-core/runtime/config.go:80-88 only honors per-provider variables:
if u := envVars["OPENAI_BASE_URL"]; u != "" && mc.Provider == "openai" { ... }
if u := envVars["ANTHROPIC_BASE_URL"]; u != "" && mc.Provider == "anthropic" { ... }
if u := envVars["OLLAMA_BASE_URL"]; u != "" && mc.Provider == "ollama" { ... }
MODEL_BASE_URL is grepped only by forge-ui (the wizard frontend) and the init renderer — no consumer in the runtime. Same story for MODEL_API_KEY (it's in runner.go:2672's builtinSecretKeys list for the secret overlay, but ResolveModelConfig never wires it into mc.Client.APIKey).
The Web UI wizard (forge-ui/handlers_create.go:81) emits the same MODEL_BASE_URL shape, so this affects both the TUI and the Web UI paths.
Expected behavior
forge init's Custom option is documented and presented as the way to point Forge at any OpenAI-compatible endpoint (OpenRouter, litellm, vLLM, Together AI, local proxies, etc.). The wizard collects all three required inputs. The output should produce a forge run that successfully constructs an LLM client and routes to the configured endpoint without manual forge.yaml / .env edits.
Suggested fix
Two plausible shapes — either works; my read is option A is the smallest correct change.
Option A — Wire Custom through the existing OpenAI-compatible path
In forge-cli/cmd/init.go, when the wizard returns ctx.CustomBaseURL != "":
- Write
provider: openai (not custom) into forge.yaml.
- Write
OPENAI_BASE_URL=<custom URL> and OPENAI_API_KEY=<custom key> into .env (instead of MODEL_BASE_URL / MODEL_API_KEY).
This needs zero changes in forge-core/. The existing OpenAI client + the existing ResolveModelConfig env-var branch handle it. Banner will read Model: openai/<custom-model-name> which is honest about the wire protocol.
Trade-off: loses the "Custom" label in the banner. Could be preserved with a comment in the generated forge.yaml if that matters.
Option B — Make custom a first-class provider type
- Add
case "custom": to forge-core/llm/providers/factory.go that constructs an OpenAIClient with cfg.BaseURL and cfg.APIKey pre-populated.
- Add
BaseURL string + BaseURLEnv string to types.ModelSpec (currently only Provider, Name, Version, OrganizationID, Fallbacks).
- Extend
ResolveModelConfig to honor MODEL_BASE_URL / MODEL_API_KEY when mc.Provider == "custom".
More invasive (touches the schema + the factory + the resolver + their tests) but keeps provider: custom as a documented YAML value with explicit semantics.
Acceptance criteria
Workaround (until fix lands)
Edit the generated files manually:
# forge.yaml
model:
provider: openai
name: moonshotai/Kimi-K2.6
# .env
OPENAI_BASE_URL=https://your-endpoint.example.com/v1
OPENAI_API_KEY=sk-...
Environment
forge built from main (commit a572b21).
- Reproduced with
provider: custom + name: moonshotai/Kimi-K2.6 against an OpenRouter-shaped endpoint.
framework: forge, Telegram channel, default builtin tools.
Summary
Picking the Custom provider during
forge init(or via the Web UI wizard) writes aforge.yaml+.envpair that the runtime cannot consume. Atforge runtime the agent falls back toStubExecutorand every request fails with:The wizard collects the custom URL, model, and API key correctly — it just writes them to keys that the runtime never reads, while simultaneously writing a
provider:value that the LLM client factory rejects outright.Reproduction
forge init→ choose Custom provider → enter base URL, model name (e.g.moonshotai/Kimi-K2.6), API key.forge run(orforge serve).Observed
forge.yamlObserved
.envObserved log
If you look just upstream you'll also find:
Root cause
Two disconnected sides of the same feature.
1. The factory rejects
provider: custom—forge-core/llm/providers/factory.go:11-27There is no
"custom"case.Runner.buildLLMClientpropagates the error and the runner falls back toStubExecutoratforge-cli/runtime/runner.go:541-544.2. The env vars the wizard writes are never read at runtime —
forge-cli/cmd/init.go:389-395But
ResolveModelConfigatforge-core/runtime/config.go:80-88only honors per-provider variables:MODEL_BASE_URLis grepped only byforge-ui(the wizard frontend) and the init renderer — no consumer in the runtime. Same story forMODEL_API_KEY(it's inrunner.go:2672'sbuiltinSecretKeyslist for the secret overlay, butResolveModelConfignever wires it intomc.Client.APIKey).The Web UI wizard (
forge-ui/handlers_create.go:81) emits the sameMODEL_BASE_URLshape, so this affects both the TUI and the Web UI paths.Expected behavior
forge init's Custom option is documented and presented as the way to point Forge at any OpenAI-compatible endpoint (OpenRouter, litellm, vLLM, Together AI, local proxies, etc.). The wizard collects all three required inputs. The output should produce aforge runthat successfully constructs an LLM client and routes to the configured endpoint without manualforge.yaml/.envedits.Suggested fix
Two plausible shapes — either works; my read is option A is the smallest correct change.
Option A — Wire Custom through the existing OpenAI-compatible path
In
forge-cli/cmd/init.go, when the wizard returnsctx.CustomBaseURL != "":provider: openai(notcustom) intoforge.yaml.OPENAI_BASE_URL=<custom URL>andOPENAI_API_KEY=<custom key>into.env(instead ofMODEL_BASE_URL/MODEL_API_KEY).This needs zero changes in
forge-core/. The existing OpenAI client + the existingResolveModelConfigenv-var branch handle it. Banner will readModel: openai/<custom-model-name>which is honest about the wire protocol.Trade-off: loses the "Custom" label in the banner. Could be preserved with a comment in the generated
forge.yamlif that matters.Option B — Make
customa first-class provider typecase "custom":toforge-core/llm/providers/factory.gothat constructs anOpenAIClientwithcfg.BaseURLandcfg.APIKeypre-populated.BaseURL string+BaseURLEnv stringtotypes.ModelSpec(currently onlyProvider,Name,Version,OrganizationID,Fallbacks).ResolveModelConfigto honorMODEL_BASE_URL/MODEL_API_KEYwhenmc.Provider == "custom".More invasive (touches the schema + the factory + the resolver + their tests) but keeps
provider: customas a documented YAML value with explicit semantics.Acceptance criteria
forge init→ Custom →forge run(no manual edits) → primary LLM client constructs and serves a request end-to-end.forge-cli/cmd/init_test.gopins the generatedforge.yaml+.envshape for the Custom path.forge-cli/runtime/provesRunner.buildLLMClientsucceeds for the Custom config shape (usinghttptest.NewServeras the OpenAI-compatible target).forge-ui/handlers_create.go:81produces the same shape as the TUI.provider: customin their checked-inforge.yaml.Workaround (until fix lands)
Edit the generated files manually:
Environment
forgebuilt frommain(commita572b21).provider: custom+name: moonshotai/Kimi-K2.6against an OpenRouter-shaped endpoint.framework: forge, Telegram channel, default builtin tools.