Skip to content

Commit 45ecc66

Browse files
authored
faet: add base-url for custom providers (#15)
1 parent 8b6ff02 commit 45ecc66

File tree

5 files changed

+152
-52
lines changed

5 files changed

+152
-52
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ Some plugins (e.g., Claude Code) validate model aliases via `models.yaml`. If an
176176
- Plugins default to `pitaya-agents:latest`; override per run with `--docker-image <repo/name:tag>`
177177
- Full isolation per instance: dedicated container, workspace mount, and session volume
178178

179+
### OpenAI‑Compatible Providers (Codex)
180+
181+
- Pass `--api-key` and `--base-url` to target any OpenAI‑compatible endpoint. Pitaya configures the Codex CLI provider under the hood.
182+
- Example (OpenRouter):
183+
184+
```bash
185+
pitaya "hello" \
186+
--plugin codex \
187+
--model "openai/gpt-5" \
188+
--api-key "$OPENROUTER_API_KEY" \
189+
--base-url https://openrouter.ai/api/v1
190+
```
191+
192+
- This works with any OpenAI‑compatible API (self‑hosted or proxy). The API key is provided to the container via `OPENAI_API_KEY`.
193+
194+
### Anthropic (Claude Code)
195+
196+
- Use `--oauth-token` (subscription) or `--api-key` (API mode). Optional `--base-url` is respected as `ANTHROPIC_BASE_URL`.
197+
- Env alternatives: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_BASE_URL`.
198+
179199
## Logs & Artifacts
180200

181201
- Logs: `logs/<run_id>/events.jsonl` and structured component logs

src/cli.py

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ def create_parser(cls) -> argparse.ArgumentParser:
237237
"--api-key",
238238
help="API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY)",
239239
)
240+
g_auth.add_argument(
241+
"--base-url",
242+
help="Custom API base URL (e.g., OpenAI/Anthropic proxy)",
243+
)
240244

241245
# Execution & limits group
242246
g_limits = parser.add_argument_group("Execution & Limits")
@@ -363,6 +367,8 @@ def _get_auth_config(
363367
cli_config.setdefault("runner", {})["oauth_token"] = args.oauth_token
364368
if hasattr(args, "api_key") and args.api_key:
365369
cli_config.setdefault("runner", {})["api_key"] = args.api_key
370+
if hasattr(args, "base_url") and args.base_url:
371+
cli_config.setdefault("runner", {})["base_url"] = args.base_url
366372
# Include plugin selection at top-level for strategy-agnostic wiring
367373
if hasattr(args, "plugin") and args.plugin:
368374
cli_config["plugin_name"] = args.plugin
@@ -372,10 +378,24 @@ def _get_auth_config(
372378
cli_config, env_config, dotenv_config, config or {}, defaults
373379
)
374380

375-
# Extract auth values from merged config
376-
oauth_token = full_config.get("runner", {}).get("oauth_token")
377-
api_key = full_config.get("runner", {}).get("api_key")
378-
base_url = full_config.get("runner", {}).get("base_url")
381+
# Extract auth values from merged config; select by plugin for consistency
382+
runner_cfg = full_config.get("runner", {})
383+
plugin_name = full_config.get("plugin_name") or getattr(
384+
args, "plugin", "claude-code"
385+
)
386+
387+
if str(plugin_name) == "codex":
388+
# Prefer OpenAI keys; allow CLI overrides via generic api_key/base_url
389+
api_key = runner_cfg.get("api_key") or runner_cfg.get("openai_api_key")
390+
base_url = runner_cfg.get("base_url") or runner_cfg.get("openai_base_url")
391+
oauth_token = None
392+
else:
393+
# Claude Code: prefer OAuth, then Anthropic API key
394+
oauth_token = runner_cfg.get("oauth_token")
395+
api_key = runner_cfg.get("api_key") or runner_cfg.get("anthropic_api_key")
396+
base_url = runner_cfg.get("base_url") or runner_cfg.get(
397+
"anthropic_base_url"
398+
)
379399

380400
# Apply mode selection logic per spec section 6.1
381401
# 1. If --mode api specified, use API key
@@ -412,15 +432,25 @@ def _get_auth_config(
412432
elif api_key:
413433
# API mode when only API key is available
414434
pass
415-
# 5. Otherwise, error with clear message
435+
# 5. Otherwise, error with clear message (fail fast for both providers)
416436
else:
417-
# Do not hard-fail: non-Anthropic plugins (e.g., Codex) may use OPENAI_API_KEY
418-
# or run in OSS mode. Warn and proceed with empty AuthConfig.
419-
self.console.print(
420-
"[yellow]Warning: No provider credentials found.\n"
421-
"- For Anthropic, set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY.\n"
422-
"- For OpenAI, ensure OPENAI_API_KEY is set (and OPENAI_BASE_URL if applicable).[/yellow]"
423-
)
437+
if str(plugin_name) == "codex":
438+
self.console.print(
439+
"[red]Error: Missing credentials for OpenAI‑compatible provider.[/red]\n"
440+
"Set OPENAI_API_KEY in:\n"
441+
" - .env file (OPENAI_API_KEY=...)\n"
442+
" - Environment variables (export OPENAI_API_KEY=...)\n"
443+
" - Command line: --api-key\n"
444+
"Optionally set --base-url (or OPENAI_BASE_URL) for non-default endpoints."
445+
)
446+
else:
447+
self.console.print(
448+
"[red]Error: Missing credentials for Anthropic.[/red]\n"
449+
"Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in:\n"
450+
" - .env file\n"
451+
" - Environment variables\n"
452+
" - Command line: --oauth-token or --api-key"
453+
)
424454
return AuthConfig(oauth_token=None, api_key=None, base_url=None)
425455

426456
return AuthConfig(oauth_token=oauth_token, api_key=api_key, base_url=base_url)
@@ -1601,6 +1631,8 @@ async def run_orchestrator(self, args: argparse.Namespace) -> int:
16011631
cli_config.setdefault("runner", {})["oauth_token"] = args.oauth_token
16021632
if hasattr(args, "api_key") and args.api_key:
16031633
cli_config.setdefault("runner", {})["api_key"] = args.api_key
1634+
if hasattr(args, "base_url") and args.base_url:
1635+
cli_config.setdefault("runner", {})["base_url"] = args.base_url
16041636

16051637
# Apply proxy flags to environment early so downstream components observe them
16061638
# Proxy environment mapping removed
@@ -1766,29 +1798,38 @@ def _red(k, v):
17661798

17671799
retry_config = RetryConfig(max_attempts=3)
17681800

1769-
# Create auth config from merged configuration
1770-
oauth_token = full_config.get("runner", {}).get("oauth_token")
1771-
api_key = full_config.get("runner", {}).get("api_key")
1772-
base_url = full_config.get("runner", {}).get("base_url")
1773-
1774-
if not oauth_token and not api_key:
1775-
# Allow non-Anthropic plugins to continue (e.g., Codex with OPENAI_API_KEY)
1776-
plugin_name = full_config.get("plugin_name") or getattr(
1777-
args, "plugin", "claude-code"
1778-
)
1779-
if str(plugin_name) == "claude-code":
1801+
# Create auth config from merged configuration (plugin-aware)
1802+
plugin_name = full_config.get("plugin_name") or getattr(
1803+
args, "plugin", "claude-code"
1804+
)
1805+
rcfg = full_config.get("runner", {})
1806+
if str(plugin_name) == "codex":
1807+
api_key = rcfg.get("api_key") or rcfg.get("openai_api_key")
1808+
base_url = rcfg.get("base_url") or rcfg.get("openai_base_url")
1809+
oauth_token = None
1810+
if not api_key:
17801811
self.console.print(
1781-
"[red]Error: Missing credentials for the selected provider.[/red]\n"
1812+
"[red]Error: Missing credentials for OpenAI‑compatible provider.[/red]\n"
1813+
"Set OPENAI_API_KEY in:\n"
1814+
" - .env file (OPENAI_API_KEY=...)\n"
1815+
" - Environment variables (export OPENAI_API_KEY=...)\n"
1816+
" - Command line: --api-key\n"
1817+
"Optionally set --base-url (or OPENAI_BASE_URL) for non-default endpoints."
1818+
)
1819+
return 1
1820+
else:
1821+
oauth_token = rcfg.get("oauth_token")
1822+
api_key = rcfg.get("api_key") or rcfg.get("anthropic_api_key")
1823+
base_url = rcfg.get("base_url") or rcfg.get("anthropic_base_url")
1824+
if not oauth_token and not api_key:
1825+
self.console.print(
1826+
"[red]Error: Missing credentials for Anthropic.[/red]\n"
17821827
"Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in:\n"
17831828
" - .env file\n"
17841829
" - Environment variables\n"
17851830
" - Command line: --oauth-token or --api-key"
17861831
)
17871832
return 1
1788-
else:
1789-
self.console.print(
1790-
"[yellow]Warning: No provider credentials set. Proceeding with selected plugin.[/yellow]"
1791-
)
17921833

17931834
auth_config = AuthConfig(
17941835
oauth_token=oauth_token, api_key=api_key, base_url=base_url

src/config.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ def load_dotenv_config(dotenv_path: Optional[Path] = None) -> Dict[str, Any]:
147147
148148
Supports standard environment variables:
149149
- CLAUDE_CODE_OAUTH_TOKEN
150-
- ANTHROPIC_API_KEY
151-
- ANTHROPIC_BASE_URL
150+
- ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL
151+
- OPENAI_API_KEY / OPENAI_BASE_URL
152152
153153
Args:
154154
dotenv_path: Path to .env file (default: .env in current directory)
@@ -178,12 +178,22 @@ def load_dotenv_config(dotenv_path: Optional[Path] = None) -> Dict[str, Any]:
178178
if "ANTHROPIC_API_KEY" in env_values:
179179
if "runner" not in config:
180180
config["runner"] = {}
181-
config["runner"]["api_key"] = env_values["ANTHROPIC_API_KEY"]
181+
config["runner"]["anthropic_api_key"] = env_values["ANTHROPIC_API_KEY"]
182182

183183
if "ANTHROPIC_BASE_URL" in env_values:
184184
if "runner" not in config:
185185
config["runner"] = {}
186-
config["runner"]["base_url"] = env_values["ANTHROPIC_BASE_URL"]
186+
config["runner"]["anthropic_base_url"] = env_values["ANTHROPIC_BASE_URL"]
187+
188+
if "OPENAI_API_KEY" in env_values:
189+
if "runner" not in config:
190+
config["runner"] = {}
191+
config["runner"]["openai_api_key"] = env_values["OPENAI_API_KEY"]
192+
193+
if "OPENAI_BASE_URL" in env_values:
194+
if "runner" not in config:
195+
config["runner"] = {}
196+
config["runner"]["openai_base_url"] = env_values["OPENAI_BASE_URL"]
187197

188198
return config
189199

@@ -192,7 +202,7 @@ def load_env_config() -> Dict[str, Any]:
192202
"""
193203
Load environment variables into a config dict.
194204
195-
Supports standard names (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY).
205+
Supports standard names (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_*, OPENAI_*).
196206
"""
197207
config: Dict[str, Any] = {}
198208

@@ -205,12 +215,22 @@ def load_env_config() -> Dict[str, Any]:
205215
if "ANTHROPIC_API_KEY" in os.environ:
206216
if "runner" not in config:
207217
config["runner"] = {}
208-
config["runner"]["api_key"] = os.environ["ANTHROPIC_API_KEY"]
218+
config["runner"]["anthropic_api_key"] = os.environ["ANTHROPIC_API_KEY"]
209219

210220
if "ANTHROPIC_BASE_URL" in os.environ:
211221
if "runner" not in config:
212222
config["runner"] = {}
213-
config["runner"]["base_url"] = os.environ["ANTHROPIC_BASE_URL"]
223+
config["runner"]["anthropic_base_url"] = os.environ["ANTHROPIC_BASE_URL"]
224+
225+
if "OPENAI_API_KEY" in os.environ:
226+
if "runner" not in config:
227+
config["runner"] = {}
228+
config["runner"]["openai_api_key"] = os.environ["OPENAI_API_KEY"]
229+
230+
if "OPENAI_BASE_URL" in os.environ:
231+
if "runner" not in config:
232+
config["runner"] = {}
233+
config["runner"]["openai_base_url"] = os.environ["OPENAI_BASE_URL"]
214234

215235
# No Pitaya-specific env variables; use CLI or config file
216236

src/instance_runner/plugins/codex.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def capabilities(self) -> PluginCapabilities:
4545
supports_streaming=True,
4646
supports_cost_limits=False,
4747
requires_auth=False, # allow OSS mode; OpenAI auth optional
48-
auth_methods=["api_key", "oauth", "oss"],
48+
auth_methods=["api_key", "oss"], # reflect actual supported auth paths
4949
)
5050

5151
async def validate_environment(
@@ -84,22 +84,7 @@ async def prepare_environment(
8484
if "OPENAI_BASE_URL" in os.environ:
8585
env.setdefault("OPENAI_BASE_URL", os.environ["OPENAI_BASE_URL"])
8686

87-
# Fallback to .env file for OPENAI_* if not present in process environment
88-
if "OPENAI_API_KEY" not in env or not env.get("OPENAI_API_KEY"):
89-
try:
90-
from dotenv import dotenv_values # type: ignore
91-
92-
values = dotenv_values()
93-
if values and values.get("OPENAI_API_KEY"):
94-
env["OPENAI_API_KEY"] = str(values.get("OPENAI_API_KEY"))
95-
if (
96-
values
97-
and values.get("OPENAI_BASE_URL")
98-
and "OPENAI_BASE_URL" not in env
99-
):
100-
env["OPENAI_BASE_URL"] = str(values.get("OPENAI_BASE_URL"))
101-
except Exception:
102-
pass
87+
# .env fallback is handled globally by the CLI/config merger; avoid plugin-level dotenv reads
10388

10489
# Proxy passthrough will be handled in DockerManager based on network_egress
10590
return env
@@ -140,6 +125,24 @@ async def build_command(
140125
if model:
141126
cmd += ["-m", model]
142127

128+
# Custom provider wiring: when a base URL is provided (via runner), configure
129+
# Codex CLI provider using -c flags. This is required for non-default endpoints.
130+
provider_base_url = kwargs.get("provider_base_url")
131+
provider_env_key = kwargs.get("provider_env_key") or "OPENAI_API_KEY"
132+
if provider_base_url:
133+
# Select a custom provider key and map model + provider config
134+
cmd += ["-c", "model_provider=pitaya_custom"]
135+
if model:
136+
cmd += ["-c", f'model="{model}"']
137+
# Brace escaping for f-string: double {{ }} to emit literal braces
138+
cmd += [
139+
"-c",
140+
(
141+
f"model_providers.pitaya_custom="
142+
f'{{ name="CustomProvider", base_url="{provider_base_url}", env_key="{provider_env_key}" }}'
143+
),
144+
]
145+
143146
# Resume/reattach: experimental; accept session_id when provided
144147
# (Codex may expect a rollout path; we pass session id to be safe if supported)
145148
if session_id:

src/instance_runner/runner.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,21 @@ def emit_event(event_type: str, data: Dict[str, Any]) -> None:
615615
)
616616

617617
# Execute via plugin interface
618+
# Provide provider hints to Codex when custom base URL is configured
619+
codex_provider_kwargs = {}
620+
try:
621+
if getattr(plugin, "name", "") == "codex":
622+
purl = None
623+
if auth_config and getattr(auth_config, "base_url", None):
624+
purl = auth_config.base_url
625+
if not purl:
626+
purl = (env_vars or {}).get("OPENAI_BASE_URL")
627+
if purl:
628+
codex_provider_kwargs["provider_base_url"] = purl
629+
codex_provider_kwargs["provider_env_key"] = "OPENAI_API_KEY"
630+
except Exception:
631+
pass
632+
618633
result_data = await plugin.execute(
619634
docker_manager=docker_manager,
620635
container=container,
@@ -630,6 +645,7 @@ def emit_event(event_type: str, data: Dict[str, Any]) -> None:
630645
operator_resume=operator_resume,
631646
max_turns=max_turns,
632647
stream_log_path=log_path,
648+
**codex_provider_kwargs,
633649
)
634650

635651
agent_session_id = result_data.get("session_id")

0 commit comments

Comments
 (0)