Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions oddish/src/oddish/workers/harbor/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,35 @@
"GOOGLE_GENERATIVE_AI_API_KEY",
"GOOGLE_API_KEY",
)
# The name the Vercel AI SDK's google provider reads, and therefore the name
# opencode authenticates with. The platform publishes its Google key as
# GEMINI_API_KEY (what gemini-cli reads), so the two have to be bridged.
_AI_SDK_GOOGLE_KEY = "GOOGLE_GENERATIVE_AI_API_KEY"
# The ambient names that hold the platform Google credential, best first.
_GOOGLE_KEY_SOURCES = ("GEMINI_API_KEY", "GOOGLE_API_KEY")


def _gemini_ai_sdk_alias_env(model: str | None) -> dict[str, str]:
"""Mirror the ambient Google key onto the AI SDK's env var name.

Returns ``{}`` unless this is a Google-provider trial whose credential is
ambient under a different name. Never overwrites an explicitly-set
``GOOGLE_GENERATIVE_AI_API_KEY``, so a deployment that configures the AI SDK
name directly stays authoritative.

This has to happen in the worker's os.environ rather than the agent env:
Harbor's opencode agent selects the provider keys it forwards with a raw
``if key in os.environ`` test, bypassing the extra_env-aware accessor, so a
value injected only through the agent env is invisible to it.
"""
if infer_model_provider_prefix(model) != "gemini":
return {}
if os.environ.get(_AI_SDK_GOOGLE_KEY):
return {}
for source in _GOOGLE_KEY_SOURCES:
if value := os.environ.get(source):
return {_AI_SDK_GOOGLE_KEY: value}
return {}
# Ambient claude-code platform credentials must fold into the redaction map for
# the same reason: when job-scoped injection is off, the direct/OAuth Anthropic
# credential or the Bedrock credential chain the stock agent forwards is only in
Expand Down Expand Up @@ -1757,6 +1786,16 @@ async def _run_harbor_trial_async_impl(
# Keep the BYOK key ambient for Job.create/run too, so Harbor's own
# os.environ-based Bedrock-mode check agrees with the direct model id.
runtime_env.update(byok_anthropic_env)
# Same idea for Google: the platform key is published as GEMINI_API_KEY
# (what gemini-cli reads), but agents built on the Vercel AI SDK read
# GOOGLE_GENERATIVE_AI_API_KEY. Harbor's opencode agent forwards the
# provider's key names it finds in *os.environ* -- a raw membership
# test, not the extra_env-aware helper -- so a value that arrives only
# through the agent env never reaches it, and the CLI exits before its
# first model call with no credential it recognises. Mirroring the one
# platform key onto the AI SDK name here puts it on the exact surface
# that forwarding reads, for the whole Job.create/run scope.
runtime_env.update(_gemini_ai_sdk_alias_env(model))
is_claude_code = "claude-code" in (agent or "").strip().lower()
if is_claude_code and (
byok_anthropic_env or _claude_code_forces_direct_api(is_probe)
Expand Down
43 changes: 43 additions & 0 deletions oddish/tests/test_harbor_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,49 @@ def test_opencode_google_model_folds_ai_sdk_credential(monkeypatch):
assert replacements["gsdk-secret-789"] == "[REDACTED]"


def test_gemini_ai_sdk_alias_mirrors_ambient_google_key(monkeypatch):
# opencode authenticates with GOOGLE_GENERATIVE_AI_API_KEY (the AI SDK
# name), but the platform publishes its Google key as GEMINI_API_KEY (what
# gemini-cli reads). Without the mirror the CLI finds no credential it
# recognises and exits before its first model call.
monkeypatch.delenv("GOOGLE_GENERATIVE_AI_API_KEY", raising=False)
monkeypatch.setenv("GEMINI_API_KEY", "gm-secret-123")
assert harbor_runner._gemini_ai_sdk_alias_env("google/gemini-3.7-flash") == {
"GOOGLE_GENERATIVE_AI_API_KEY": "gm-secret-123"
}


def test_gemini_ai_sdk_alias_falls_back_to_google_api_key(monkeypatch):
monkeypatch.delenv("GOOGLE_GENERATIVE_AI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "goog-secret-456")
assert harbor_runner._gemini_ai_sdk_alias_env("google/gemini-3.7-flash") == {
"GOOGLE_GENERATIVE_AI_API_KEY": "goog-secret-456"
}


def test_gemini_ai_sdk_alias_never_overwrites_an_explicit_value(monkeypatch):
# A deployment that configures the AI SDK name directly stays authoritative.
monkeypatch.setenv("GOOGLE_GENERATIVE_AI_API_KEY", "explicit-789")
monkeypatch.setenv("GEMINI_API_KEY", "gm-secret-123")
assert harbor_runner._gemini_ai_sdk_alias_env("google/gemini-3.7-flash") == {}


def test_gemini_ai_sdk_alias_skips_non_google_providers(monkeypatch):
# Least privilege: an OpenAI or Anthropic trial must not carry a Google key.
monkeypatch.delenv("GOOGLE_GENERATIVE_AI_API_KEY", raising=False)
monkeypatch.setenv("GEMINI_API_KEY", "gm-secret-123")
assert harbor_runner._gemini_ai_sdk_alias_env("openai/gpt-5") == {}
assert harbor_runner._gemini_ai_sdk_alias_env("anthropic/claude-sonnet-4-5") == {}
assert harbor_runner._gemini_ai_sdk_alias_env(None) == {}


def test_gemini_ai_sdk_alias_noop_without_any_google_key(monkeypatch):
for var in ("GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"):
monkeypatch.delenv(var, raising=False)
assert harbor_runner._gemini_ai_sdk_alias_env("google/gemini-3.7-flash") == {}


def test_claude_code_ambient_credentials_enter_redaction_map(monkeypatch):
# Ambient claude-code platform credentials (used when job-scoped injection is
# off) must fold into the trial transport env so their raw values are redacted
Expand Down
Loading