You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An operator runs a Forge agent against an OpenAI-compatible provider (Together.ai, OpenRouter, Groq, Fireworks, Anyscale, vLLM, llama.cpp's server, ...) by setting:
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-99SkillCommandExecutor.Run builds a whitelist-only env for skill subprocesses:
env:= []string{
"PATH="+os.Getenv("PATH"),
"HOME="+os.Getenv("HOME"),
}
for_, name:=rangee.EnvVars { // ← only env vars declared in SKILL.mdifval:=os.Getenv(name); val!="" {
env=append(env, name+"="+val)
}
}
iforgID:=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:
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.
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",
} {
ifv:=os.Getenv(name); v!="" {
env=append(env, name+"="+v)
}
}
iforgID:=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.
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/v1The main agent loop correctly hits Together.ai because the LLM provider client reads
OPENAI_BASE_URLfrom the process env at construction time.A skill subprocess that calls the LLM API directly (e.g.
code-review'scode-review-diff.sh) does NOT seeOPENAI_BASE_URL. The script falls back tohttps://api.openai.com/v1as its default, then POSTs the Together.ai key to OpenAI: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-99SkillCommandExecutor.Runbuilds a whitelist-only env for skill subprocesses:OPENAI_ORG_IDis 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 declareOPENAI_BASE_URL(etc.) in its SKILL.mdenv.optionallist — and every skill that forgets silently breaks for operators using OpenAI-compatible providers.The
e.EnvVarswhitelist is populated atrunner.go:2682-2687from the skill's declaredrequired/one_of/optionalenv requirements:So unless the SKILL.md declares it,
OPENAI_BASE_URLnever reaches the subprocess.Why this is a Forge-level bug, not per-skill
OPENAI_BASE_URLis 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 theOPENAI_ORG_IDspecial 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 (defaulthttp://localhost:11434is wrong any time the model server isn't on the agent host).GEMINI_BASE_URL— same pattern for Gemini.Proposed fix
Extend
SkillCommandExecutor.Runatforge-cli/tools/exec.go:82-84to always-pass the standard provider base URL env vars when present: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 forcode-review'sOPENAI_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 inSkillCommandExecutoris the right shape.Tests
forge-cli/tools/exec_test.go:TestSkillCommandExecutor_OpenAIBaseURLIsPassedThrough— setOPENAI_BASE_URL=https://api.together.ai/v1in the process env, run a skill that doesn't declare it inEnvVars, assert the subprocessenvcontainsOPENAI_BASE_URL=https://api.together.ai/v1.ANTHROPIC_BASE_URL,OLLAMA_BASE_URL,GEMINI_BASE_URL.TestSkillCommandExecutor_BaseURLNotSet_NotEmitted— unset, confirm the subprocess env doesn't gain anOPENAI_BASE_URL=line at all (omit-when-empty semantic matches the existingOPENAI_ORG_IDshape).Out of scope
WEB_SEARCH_BASE_URLetc — web-search skills can be handled by adding the variable to theirenv.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.*_BASE_URL"). Too coarse — could leak operator-specific env vars to subprocesses unintentionally. Explicit allowlist of known SDK vars is the right shape.References
/Users/.../agentdemo/pr-reviewerwithOPENAI_BASE_URL=https://api.together.ai/v1in.env. The main agent loop hits Together.ai correctly. Thecode_review_diffskill subprocess hitsapi.openai.comand 401s.code-reviewspecifically) — necessary for the routing logic inside the script. This issue is the orthogonal env-propagation gap underneath.OPENAI_ORG_IDspecial case atexec.go:82-84is the precedent — the same treatment should apply to the URL pointer.