Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
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
175 changes: 162 additions & 13 deletions oddish/src/oddish/config.py
Original file line number Diff line number Diff line change
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,67 @@ 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 _dashed_claude_alias(bare_id: str) -> str:
"""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 Anthropic API only knows dashed ids.

Claude-shaped ids only: OpenAI slugs ("gpt-5.3-codex") are legitimately
dotted, and so are Bedrock ids ("anthropic.claude-...-v1:0"), so callers
must pass an id that is already stripped to its bare Anthropic form.
"""
if "claude" not in bare_id.lower():
return bare_id
return bare_id.replace(".", "-")


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
):
return _dashed_claude_alias(str(bare).strip())
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 +759,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 @@ -814,6 +890,11 @@ def to_anthropic_api_model_id(model: str | None) -> str | None:
Anthropic API (``ANTHROPIC_API_KEY``) rather than Bedrock -- e.g. the probe
summary analyzer. Plain Claude ids keep their value (minus an
``anthropic/``/``claude/`` provider prefix); non-Claude ids pass through.

A prefixed id resolves its dotted marketing spelling the same way the trial
path does (``anthropic_platform_bare_model_id``), so ``anthropic/`` ids
reach the API identically whether they arrive through a trial or through an
analyzer block.
"""
if model is None:
return None
Expand All @@ -834,7 +915,7 @@ def to_anthropic_api_model_id(model: str | None) -> str | None:
# any other provider prefix is a deliberate transport choice -- pass through.
provider_prefix, bare = split_provider_model_name(stripped)
if provider_prefix and provider_prefix.strip().lower() in {"anthropic", "claude"}:
return bare
return _dashed_claude_alias(str(bare).strip())
return stripped


Expand Down Expand Up @@ -1450,8 +1531,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 +1745,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 +1771,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 +1787,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 +1872,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 +1939,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 +1995,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 +2008,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 +2036,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