Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 29 additions & 4 deletions oddish/src/oddish/blocks/analyzer/analyzer_llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
usage_from_openai_completion,
)
from oddish.blocks.analyzer.claude_cli_client import ClaudeCliClient, CliConfig
from oddish.config import OPENAI_PROVIDER_OPENAI, _infer_provider_prefix, settings
from oddish.config import (
OPENAI_PROVIDER_OPENAI,
_infer_provider_prefix,
is_openai_platform_prefixed,
settings,
to_anthropic_api_model_id,
)

_DEFAULT_MODEL = "claude-opus-4-8"

Expand Down Expand Up @@ -142,13 +148,27 @@ def _build_openai_client(
*, model: str, api_key: str | None = None
) -> tuple[AsyncOpenAI, str]:
"""Resolve public-OpenAI vs Azure and return (client, runtime model id).

Routed per model id (``get_openai_route_for_model``): an explicit
``openai/`` id runs on the public platform, ``azure/`` on Azure, and the
bare analyzer/verdict defaults follow ODDISH_OPENAI_PROVIDER as before.
A module-level seam: tests patch this instead of the class, so construction
never needs live credentials."""
provider = settings.get_openai_provider()
provider = settings.get_openai_route_for_model(model)
Comment thread
cursor[bot] marked this conversation as resolved.
if provider == OPENAI_PROVIDER_OPENAI:
warnings.warn(settings.get_public_openai_warning(), stacklevel=2)
# Warn only when the GLOBAL default drives a bare id to the public
# route; an explicit ``openai/`` id is an intentional per-model choice
# and stays quiet (same gate as the Harbor agent path).
if settings.get_openai_provider() == OPENAI_PROVIDER_OPENAI and (
not is_openai_platform_prefixed(model)
):
warnings.warn(settings.get_public_openai_warning(), stacklevel=2)
Comment thread
cursor[bot] marked this conversation as resolved.
public = settings.require_public_openai_config(api_key=api_key)
return AsyncOpenAI(api_key=public["api_key"]), model
# The platform API only knows bare slugs -- strip the transport
# prefix from the wire model (Harbor agents do their own stripping;
# the Azure branch resolves a deployment instead).
wire_model = model.split("/", 1)[1] if is_openai_platform_prefixed(model) else model
return AsyncOpenAI(api_key=public["api_key"]), wire_model
Comment thread
cursor[bot] marked this conversation as resolved.

azure = settings.require_azure_openai_config()
deployment = settings.resolve_azure_openai_deployment(model)
Expand Down Expand Up @@ -209,6 +229,11 @@ def __init__(
# When set, the response is constrained to this JSON schema during
# generation instead of being hand-written into free text.
self._output_schema = output_schema
# The Anthropic SDK only accepts plain API ids: strip an
# ``anthropic/``/``claude/`` transport prefix and map
# Bedrock-shaped ids to their dateless API id (the same
# normalization the CLI analyzer path applies).
self._model = to_anthropic_api_model_id(model) or model
Comment thread
cursor[bot] marked this conversation as resolved.
key = resolve_analyzer_api_key(api_key)
self._anthropic = AsyncAnthropic(api_key=key) if key else AsyncAnthropic()
self._openai = None
Expand Down
162 changes: 149 additions & 13 deletions oddish/src/oddish/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def nop_oracle_kind(agent: str | None) -> str | None:
HARBOR_DEFAULT_SOURCE = "https://github.com/abundant-ai/harbor"
# abundant-ai/harbor main, as resolved into both uv.lock files. Harbor PR #17
# (tool arguments and results in the tbh trajectory) merged as this commit.
HARBOR_DEFAULT_SHA = "04fa3d8d787919ff206a004e4975ecdf890ec156"
HARBOR_DEFAULT_SHA = "504c2518f65e6e9cda7421b6e1f4fbd18b982ba4"

_HARBOR_URL_PREFIXES = ("git+", "http://", "https://", "ssh://")

Expand Down Expand Up @@ -234,6 +234,20 @@ def resolve_harbor_layers(
OPENAI_PROVIDER_OPENAI = "openai"
_OPENAI_PROVIDERS: set[str] = {OPENAI_PROVIDER_AZURE, OPENAI_PROVIDER_OPENAI}


def is_openai_platform_prefixed(model: str | None) -> bool:
"""True when *model* explicitly carries the ``openai/`` transport prefix.

Distinguishes an intentional public-platform choice from a bare id that
only reaches the platform because ODDISH_OPENAI_PROVIDER=openai drives it
there — the governance warning fires for the latter only.
"""
normalized = normalize_model_id(model)
if not normalized:
return False
provider_prefix, _ = split_provider_model_name(normalized)
return (provider_prefix or "").strip().lower() == OPENAI_PROVIDER_OPENAI

# Cross-region inference profile prefixes used for AWS Bedrock model ids, e.g.
# "global.anthropic.claude-haiku-4-5-20251001-v1:0".
_BEDROCK_REGION_PREFIXES: tuple[str, ...] = ("us.", "eu.", "apac.", "apn.", "global.")
Expand Down Expand Up @@ -633,6 +647,59 @@ def to_anthropic_hdo_model_id(model: str | None) -> str | None:
return f"{ANTHROPIC_HDO_PROVIDER}/{anthropic_hdo_bare_model_id(model)}"


# Direct Anthropic API with the platform ANTHROPIC_API_KEY. Opt-in with an
# explicit ``anthropic/<model>`` prefix: the prefix names the Anthropic
# platform the way ``bedrock/`` names Bedrock and ``anthropic-hdo/`` names the
# HDO org. Prefix-only: bare Claude ids keep the default Bedrock chokepoint.
ANTHROPIC_PLATFORM_PROVIDER = "anthropic"
_ANTHROPIC_PLATFORM_PROVIDER_PREFIXES: frozenset[str] = frozenset({"anthropic"})


def is_anthropic_platform_model(model: str | None) -> bool:
"""Return True when *model* explicitly selects the direct Anthropic API."""
if not model:
return False
raw = model.strip().lower()
if not raw:
return False
provider_prefix, _ = split_provider_model_name(raw)
return bool(
provider_prefix
and provider_prefix.strip().lower() in _ANTHROPIC_PLATFORM_PROVIDER_PREFIXES
)


def anthropic_platform_bare_model_id(model: str) -> str:
"""Strip the ``anthropic/`` prefix, returning the bare Anthropic model id."""
raw = model.strip()
provider_prefix, bare = split_provider_model_name(raw)
if (
provider_prefix
and provider_prefix.strip().lower() in _ANTHROPIC_PLATFORM_PROVIDER_PREFIXES
):
bare_id = str(bare).strip()
# Accept the dotted marketing spelling ("claude-opus-4.8") as an alias
# of the canonical dashed API id, the same tolerance the Bedrock
# chokepoint applies -- the direct API only knows dashed ids.
if "claude" in bare_id.lower():
bare_id = bare_id.replace(".", "-")
return bare_id
return raw


def to_anthropic_platform_model_id(model: str | None) -> str | None:
"""Canonicalize a platform Claude reference to ``anthropic/<bare-id>``.

Keeps platform trials off the Bedrock provider/queue bucket so they get
their own concurrency key and the runner can pin the direct-API routing
env instead of Bedrock's.
"""
if not is_anthropic_platform_model(model):
return model
assert model is not None
return f"{ANTHROPIC_PLATFORM_PROVIDER}/{anthropic_platform_bare_model_id(model)}"


def looks_like_bedrock_model_id(model: str | None) -> bool:
"""Return True if *model* is a Bedrock-style id that should route through AWS.

Expand Down Expand Up @@ -684,6 +751,7 @@ def looks_like_bedrock_model_id(model: str | None) -> bool:
# 'default' is not available for this model".
"claude-fable-5": "global.anthropic.claude-fable-5",
"claude-opus-5": "global.anthropic.claude-opus-5",
"claude-sonnet-5": "global.anthropic.claude-sonnet-5",
"claude-opus-4-8": "global.anthropic.claude-opus-4-8",
"claude-sonnet-4-6": "global.anthropic.claude-sonnet-4-6",
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
Expand Down Expand Up @@ -1450,8 +1518,12 @@ def asyncpg_server_settings(self) -> dict[str, str]:
# tarball on every click.
tasks_archive_cache_mb: int = 256

# OpenAI-family routing. Azure is the enterprise default; public OpenAI
# requires explicitly setting ODDISH_OPENAI_PROVIDER=openai.
# OpenAI-family routing default for BARE model ids (``gpt-x``, ``o3``).
# Explicit prefixes always win over this setting: ``openai/<slug>`` runs
# on the public OpenAI platform, ``azure/<slug>`` on Azure OpenAI (see
# get_openai_route_for_model). Azure stays the enterprise default for
# unprefixed ids; public OpenAI as the bare-id default requires
# explicitly setting ODDISH_OPENAI_PROVIDER=openai.
openai_provider: str = OPENAI_PROVIDER_AZURE

# API keys (read from env without ODDISH_ prefix)
Expand Down Expand Up @@ -1660,6 +1732,12 @@ def normalize_trial_model(
# with ANTHROPIC_HDO_API_KEY — must win over the Bedrock chokepoint.
if is_anthropic_hdo_model(cleaned):
return to_anthropic_hdo_model_id(cleaned)
# Explicit ``anthropic/`` keeps Claude on the direct Anthropic API with
# the platform ANTHROPIC_API_KEY — must also win over the Bedrock
# chokepoint. Bare Claude ids (and ``bedrock/``/``claude/`` forms)
# still collapse to their Bedrock runtime id below.
if is_anthropic_platform_model(cleaned):
return to_anthropic_platform_model_id(cleaned)

if strict:
return to_bedrock_model_id(cleaned)
Expand All @@ -1680,6 +1758,12 @@ def normalize_queue_key(self, model: str) -> str:
return "default"
if normalized in _PROVIDER_ONLY_QUEUE_ALIASES:
return "default"
# ``anthropic/`` names the direct Anthropic API: keep the prefixed id
# as its own queue bucket instead of collapsing it to the Bedrock id
# (``anthropic-hdo/`` survives the collapse on its own because the
# Bedrock chokepoint passes unknown provider prefixes through).
if is_anthropic_platform_model(normalized):
return to_anthropic_platform_model_id(normalized) or normalized
Comment thread
cursor[bot] marked this conversation as resolved.
normalized = _to_bedrock_model_id_if_known(normalized)
if looks_like_bedrock_model_id(normalized):
return normalized
Expand All @@ -1690,11 +1774,24 @@ def normalize_queue_key(self, model: str) -> str:
and canonical in _PROVIDER_ONLY_QUEUE_ALIASES
):
return "default"
# ``azure_openai/`` names the same transport as ``azure/``
# (get_openai_route_for_model treats them identically), so both
# spellings share one concurrency bucket.
if provider_prefix == "azure_openai":
return f"azure/{canonical}"
return normalized

inferred_prefix = _infer_provider_prefix(normalized)
if not inferred_prefix:
return normalized
# A bare OpenAI-family id runs on whatever transport the global
# default names (get_openai_route_for_model), so its concurrency
# bucket must follow that transport: bare-on-Azure shares the
# azure/<slug> bucket with explicit azure/ ids (same deployment
# quota) rather than the public-platform openai/<slug> bucket.
if inferred_prefix == "openai":
if self.get_openai_route_for_model(normalized) == OPENAI_PROVIDER_AZURE:
return f"azure/{normalized}"
Comment thread
cursor[bot] marked this conversation as resolved.
return f"{inferred_prefix}/{normalized}"

def get_queue_key_for_trial(self, agent: str, model: str | None) -> str:
Expand Down Expand Up @@ -1762,6 +1859,25 @@ def get_known_queue_keys(self) -> set[str]:
keys.update(self.model_concurrency_overrides.keys())
return keys

def get_openai_route_for_model(self, model: str | None) -> str:
"""Transport route for one OpenAI-family model id.

Explicit prefixes always win: ``openai/<slug>`` runs on the public
OpenAI platform and ``azure/<slug>`` (or ``azure_openai/``) on Azure
OpenAI, regardless of ODDISH_OPENAI_PROVIDER. Bare ids (``gpt-x``,
``o3``) keep the configured default so unprefixed traffic never
changes transport when this per-model routing evolves.
"""
normalized = normalize_model_id(model)
if normalized:
provider_prefix, _ = split_provider_model_name(normalized)
head = (provider_prefix or "").strip().lower()
if head == "openai":
return OPENAI_PROVIDER_OPENAI
if head in ("azure", "azure_openai"):
return OPENAI_PROVIDER_AZURE
return self.get_openai_provider()

def get_openai_provider(self) -> str:
provider = self.openai_provider.strip().lower()
if provider not in _OPENAI_PROVIDERS:
Expand Down Expand Up @@ -1810,13 +1926,22 @@ def resolve_azure_openai_deployment(self, model: str | None) -> str:
"ODDISH_AZURE_OPENAI_DEPLOYMENTS."
)

# Map keys are conventionally ``openai/<slug>`` (that is how the
# deployed ODDISH_AZURE_OPENAI_DEPLOYMENTS is keyed), so an explicit
# ``azure/<slug>`` or ``azure_openai/<slug>`` transport id resolves
# through the same bare and ``openai/``-keyed entries.
lookup_keys = [normalized]
if normalized.startswith("openai/"):
lookup_keys.append(normalized.split("/", 1)[1])
head, _, tail = normalized.partition("/")
if head in ("openai", "azure", "azure_openai") and tail:
lookup_keys.extend([tail, f"openai/{tail}"])
elif "/" not in normalized:
lookup_keys.append(f"openai/{normalized}")

seen: set[str] = set()
for key in lookup_keys:
if key in seen:
continue
seen.add(key)
deployment = self.azure_openai_deployments.get(key)
if deployment:
return deployment
Expand Down Expand Up @@ -1857,9 +1982,11 @@ def require_public_openai_config(
key = api_key or self.openai_api_key
if not key:
raise RuntimeError(
"OPENAI_API_KEY is required when "
"ODDISH_OPENAI_PROVIDER=openai. Azure OpenAI is the default; "
"set AZURE_OPENAI_* values to use Azure instead."
"OPENAI_API_KEY is required to run OpenAI-family jobs on the "
"public OpenAI platform (an explicit 'openai/<model>' id, or "
"ODDISH_OPENAI_PROVIDER=openai as the bare-id default). Use "
"an 'azure/<model>' id with AZURE_OPENAI_* values to run on "
"Azure OpenAI instead."
)
return {"api_key": key}

Expand All @@ -1868,11 +1995,15 @@ def get_openai_runtime_env(
) -> dict[str, str]:
"""Return process env vars for OpenAI-family provider clients.

In Azure mode this intentionally does not set ``OPENAI_API_KEY``.
Routed per model: an explicit ``openai/`` id gets the public OpenAI
platform env, an explicit ``azure/`` id the Azure env, and bare ids
follow ODDISH_OPENAI_PROVIDER (``get_openai_route_for_model``).

On the Azure route this intentionally does not set ``OPENAI_API_KEY``.
If a downstream tool ignores Azure endpoint variables, failing closed is
safer than sending task data to the public OpenAI API with an Azure key.
"""
if self.get_openai_provider() == OPENAI_PROVIDER_OPENAI:
if self.get_openai_route_for_model(model) == OPENAI_PROVIDER_OPENAI:
public = self.require_public_openai_config(api_key=api_key)
return {"OPENAI_API_KEY": public["api_key"]}

Expand All @@ -1892,9 +2023,14 @@ def get_openai_runtime_env(
def get_openai_agent_env(
self, *, model: str | None = None, api_key: str | None = None
) -> dict[str, str]:
"""Return env vars for OpenAI-family Harbor agents."""
if self.get_openai_provider() == OPENAI_PROVIDER_OPENAI:
return self.get_openai_runtime_env(api_key=api_key)
"""Return env vars for OpenAI-family Harbor agents.

Routed per model like ``get_openai_runtime_env``: ``openai/`` ids get
the public platform key, ``azure/`` ids the Azure env, bare ids the
ODDISH_OPENAI_PROVIDER default.
"""
if self.get_openai_route_for_model(model) == OPENAI_PROVIDER_OPENAI:
return self.get_openai_runtime_env(model=model, api_key=api_key)

azure = self.require_azure_openai_config()
deployment = self.resolve_azure_openai_deployment(model)
Expand Down
Loading
Loading