Skip to content

forge init 'Custom' provider produces forge.yaml the runtime cannot consume (custom URL never reaches LLM client) #83

Description

@initializ-mk

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

  1. forge init → choose Custom provider → enter base URL, model name (e.g. moonshotai/Kimi-K2.6), API key.
  2. forge run (or forge serve).
  3. 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: customforge-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 runtimeforge-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

  • forge init → Custom → forge run (no manual edits) → primary LLM client constructs and serves a request end-to-end.
  • Banner reflects the configured custom endpoint accurately.
  • Test in forge-cli/cmd/init_test.go pins the generated forge.yaml + .env shape for the Custom path.
  • Integration test in forge-cli/runtime/ proves Runner.buildLLMClient succeeds for the Custom config shape (using httptest.NewServer as the OpenAI-compatible target).
  • forge-ui/handlers_create.go:81 produces the same shape as the TUI.
  • Migration note in CHANGELOG for any users who already have provider: custom in their checked-in forge.yaml.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions