Skip to content

Skill subprocesses lose OPENAI_BASE_URL / ANTHROPIC_BASE_URL / OLLAMA_BASE_URL — only OPENAI_ORG_ID is always-passed #137

Description

@initializ-mk

Symptom

An operator runs a Forge agent against an OpenAI-compatible provider (Together.ai, OpenRouter, Groq, Fireworks, Anyscale, vLLM, llama.cpp's server, ...) by setting:

# .env
OPENAI_API_KEY=tgp_v1_d00ccf9e...
OPENAI_BASE_URL=https://api.together.ai/v1

The main agent loop correctly hits Together.ai because the LLM provider client reads OPENAI_BASE_URL from the process env at construction time.

A skill subprocess that calls the LLM API directly (e.g. code-review's code-review-diff.sh) does NOT see OPENAI_BASE_URL. The script falls back to https://api.openai.com/v1 as its default, then POSTs the Together.ai key to OpenAI:

{"error": "OpenAI API returned status 401",
 "details": {"error": {"message": "Incorrect API key provided: d00ccf9e****6706.
              You can find your API key at https://platform.openai.com/account/api-keys.",
              "type": "invalid_request_error", "code": "invalid_api_key"}}}

The OpenAI error confirms the request reached api.openai.com, not Together.ai. The key was passed (so OPENAI_API_KEY IS in the env), but OPENAI_BASE_URL was not.

Root cause

forge-cli/tools/exec.go:62-99 SkillCommandExecutor.Run builds a whitelist-only env for skill subprocesses:

env := []string{
    "PATH=" + os.Getenv("PATH"),
    "HOME=" + os.Getenv("HOME"),
}
for _, name := range e.EnvVars {  // ← only env vars declared in SKILL.md
    if val := os.Getenv(name); val != "" {
        env = append(env, name+"="+val)
    }
}
if orgID := os.Getenv("OPENAI_ORG_ID"); orgID != "" {
    env = append(env, "OPENAI_ORG_ID="+orgID)  // ← OPENAI_ORG_ID is always-passed
}

OPENAI_ORG_ID is special-cased: it's always forwarded if present in the parent env. That's correct — operators expect that variable to follow OpenAI calls.

The standard provider base URL pointers (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, OLLAMA_BASE_URL) get no equivalent treatment. Every skill that calls an LLM API directly therefore has to remember to declare OPENAI_BASE_URL (etc.) in its SKILL.md env.optional list — and every skill that forgets silently breaks for operators using OpenAI-compatible providers.

The e.EnvVars whitelist is populated at runner.go:2682-2687 from the skill's declared required / one_of / optional env requirements:

envVars = append(envVars, entry.ForgeReqs.Env.Required...)
envVars = append(envVars, entry.ForgeReqs.Env.OneOf...)
envVars = append(envVars, entry.ForgeReqs.Env.Optional...)

So unless the SKILL.md declares it, OPENAI_BASE_URL never reaches the subprocess.

Why this is a Forge-level bug, not per-skill

OPENAI_BASE_URL is the standard OpenAI-SDK env var for redirecting OpenAI-shape API calls to a compatible host. It's the OpenAI Python/Node SDKs' canonical override, it's what every OpenAI-compatible provider's docs reference, and it's what Forge's own main LLM loop reads.

If each skill author has to remember to declare it in env.optional, the failure mode is silent every time someone forgets — operators see "OpenAI 401" and assume the wrong thing about which provider was hit. The lesson the OPENAI_ORG_ID special case already learned needs to apply to the URL pointer too.

The same argument applies to:

  • ANTHROPIC_BASE_URL — operators redirecting Anthropic-shape calls (e.g. Anthropic's own Bedrock proxy, custom gateway).
  • OLLAMA_BASE_URL — operators running Ollama remotely (default http://localhost:11434 is wrong any time the model server isn't on the agent host).
  • GEMINI_BASE_URL — same pattern for Gemini.

Proposed fix

Extend SkillCommandExecutor.Run at forge-cli/tools/exec.go:82-84 to always-pass the standard provider base URL env vars when present:

// Standard provider env vars that operators expect to flow to any
// skill subprocess that calls an LLM API directly (Together.ai,
// OpenRouter, Groq, Fireworks, Anyscale via OPENAI_BASE_URL; Ollama
// served remotely via OLLAMA_BASE_URL; etc). Special-cased here for
// the same reason OPENAI_ORG_ID is below — these are standard
// SDK-recognized variables, not Forge-specific knobs that should
// require per-skill declaration.
for _, name := range []string{
    "OPENAI_BASE_URL",
    "ANTHROPIC_BASE_URL",
    "OLLAMA_BASE_URL",
    "GEMINI_BASE_URL",
} {
    if v := os.Getenv(name); v != "" {
        env = append(env, name+"="+v)
    }
}
if orgID := os.Getenv("OPENAI_ORG_ID"); orgID != "" {
    env = append(env, "OPENAI_ORG_ID="+orgID)
}

This change makes every skill that uses the standard provider env conventions just work, without each SKILL.md needing to enumerate them.

Operator's existing workaround

Declare each variable in the skill's SKILL.md env.optional. That's what PR #134 does for code-review's OPENAI_BASE_URL + OPENAI_USE_RESPONSES_API. But every new skill that talks to an LLM has to remember to do the same — and any skill that doesn't will silently break for OpenAI-compatible deployments. Centralizing the fix in SkillCommandExecutor is the right shape.

Tests

forge-cli/tools/exec_test.go:

  • TestSkillCommandExecutor_OpenAIBaseURLIsPassedThrough — set OPENAI_BASE_URL=https://api.together.ai/v1 in the process env, run a skill that doesn't declare it in EnvVars, assert the subprocess env contains OPENAI_BASE_URL=https://api.together.ai/v1.
  • Same for ANTHROPIC_BASE_URL, OLLAMA_BASE_URL, GEMINI_BASE_URL.
  • TestSkillCommandExecutor_BaseURLNotSet_NotEmitted — unset, confirm the subprocess env doesn't gain an OPENAI_BASE_URL= line at all (omit-when-empty semantic matches the existing OPENAI_ORG_ID shape).

Out of scope

  • WEB_SEARCH_BASE_URL etc — web-search skills can be handled by adding the variable to their env.optional. The LLM provider URLs are the load-bearing case because they affect every skill that calls an LLM, and the standard SDK env-var convention exists for exactly that purpose.
  • Generalized env-var allowlist policy (e.g. "always pass everything matching *_BASE_URL"). Too coarse — could leak operator-specific env vars to subprocesses unintentionally. Explicit allowlist of known SDK vars is the right shape.

References

  • Real reproducer: agent at /Users/.../agentdemo/pr-reviewer with OPENAI_BASE_URL=https://api.together.ai/v1 in .env. The main agent loop hits Together.ai correctly. The code_review_diff skill subprocess hits api.openai.com and 401s.
  • Related: fix(skills/code-review): provider routing + OpenAI-compatible endpoint (closes #133) #134 (per-skill SKILL.md fix for code-review specifically) — necessary for the routing logic inside the script. This issue is the orthogonal env-propagation gap underneath.
  • The OPENAI_ORG_ID special case at exec.go:82-84 is the precedent — the same treatment should apply to the URL pointer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingforge-cliAffects the forge-cli command-line tool (init, run, build, mcp commands)

    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