""" Configuration — every model ID, key, path and threshold in one place. Framework: the **OpenAI Agents SDK** (`openai-agents`). # The routing problem that no longer exists An earlier build of this project ran on Google ADK, whose LiteLLM bridge calls OpenAI's `/v1/chat/completions`. That endpoint refuses function tools alongside a reasoning model: BadRequestError: Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'. This pipeline is nothing but function tools driven by a reasoning model, so it hit that on the first call, and the fix was an ugly model-string rewrite (`openai/responses/gpt-5.6-luna`) plus a global LiteLLM flag. The Agents SDK talks to **/v1/responses natively** — a bare OpenAI model name resolves to `OpenAIResponsesModel`. Tools and reasoning coexist. All of that routing machinery is deleted, not ported. If you are reading this while porting something else off ADK: that was the whole bug. Non-OpenAI providers still go through LiteLLM (`LitellmModel`), which does use chat-completions — fine, because the tools/reasoning split is OpenAI-specific. """ from __future__ import annotations import os from pathlib import Path try: # optional — the pipeline runs from a plain environment too from dotenv import load_dotenv load_dotenv(Path(__file__).resolve().parent.parent / ".env") except ImportError: # pragma: no cover pass # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- PROJECT_ROOT = Path(__file__).resolve().parent.parent OUTPUT_ROOT = Path(os.getenv("FOSSIL_OUTPUT_DIR", str(PROJECT_ROOT / "output"))) SKILLS_ROOT = PROJECT_ROOT / "skills" # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- # # FOSSIL_MODEL_ID is the single knob for the whole pipeline. # # "gpt-5.6-luna" -> OpenAI, /v1/responses, native. The default. # "anthropic/claude-..." -> LiteLLM bridge. # "openrouter/..." -> LiteLLM bridge. # # A provider prefix ("/") is what routes a model through LiteLLM. # Anything else is handed to the SDK as-is and resolves to OpenAI's Responses API. def _normalise_model_id(model_id: str) -> str: """ Migration guard: "openai/gpt-5.6-luna" -> "gpt-5.6-luna". Under Google ADK, the "openai/" prefix was REQUIRED — it told LiteLLM which provider to use. Under the Agents SDK the same prefix means the opposite: it routes the model AWAY from the native OpenAI path and through LiteLLM, i.e. back to /v1/chat/completions, i.e. straight back into the BadRequestError this migration exists to eliminate. An old .env would therefore fail in a way that looks nothing like its cause. So strip it, and say so. """ if model_id.startswith("openai/") and not model_id.startswith("openai/responses/"): stripped = model_id.split("/", 1)[1] print( f"[config] FOSSIL_MODEL_ID={model_id!r} -> {stripped!r}. The 'openai/' " f"prefix was an ADK/LiteLLM requirement; under the Agents SDK it would " f"route you back to /v1/chat/completions. Update your .env.", ) return stripped if model_id.startswith("openai/responses/"): stripped = model_id.rsplit("/", 1)[1] print(f"[config] FOSSIL_MODEL_ID={model_id!r} -> {stripped!r}. The Agents SDK " f"uses /v1/responses natively; the routing hack is no longer needed.") return stripped return model_id ORCHESTRATOR_MODEL_ID = _normalise_model_id(os.getenv("FOSSIL_MODEL_ID", "gpt-5.6-luna")) VISION_MODEL_ID = _normalise_model_id( os.getenv("FOSSIL_VISION_MODEL_ID", ORCHESTRATOR_MODEL_ID)) # low | medium | high. Keypoint selection IS the model looking at a fossil and # thinking about where the points go, so this is load-bearing — not a knob to turn # down for speed without expecting worse masks. REASONING_EFFORT = os.getenv("FOSSIL_REASONING_EFFORT", "medium") def is_litellm_model(model_id: str) -> bool: """A provider prefix routes through LiteLLM. Bare names go to OpenAI.""" return "/" in model_id def build_model(model_id: str): """Return something `Agent(model=...)` accepts.""" if not is_litellm_model(model_id): # The SDK resolves a bare name to OpenAIResponsesModel — /v1/responses, # where function tools and reasoning coexist. Nothing to do. return model_id from agents.extensions.models.litellm_model import LitellmModel return LitellmModel(model=model_id) def build_model_settings(model_id: str): """ ModelSettings for a model. Reasoning effort is only meaningful on OpenAI reasoning models; sending it to a LiteLLM provider that has never heard of it is a 400 waiting to happen, so it is set only on the native path. """ from agents import ModelSettings if is_litellm_model(model_id): return ModelSettings() from openai.types.shared import Reasoning return ModelSettings(reasoning=Reasoning(effort=REASONING_EFFORT)) def describe_routing(model_id: str) -> dict: """What will actually be sent. Used by `python main.py --doctor`.""" from importlib.metadata import version litellm_path = is_litellm_model(model_id) try: sdk = version("openai-agents") except Exception: # noqa: BLE001 sdk = "unknown" return { "FOSSIL_MODEL_ID": model_id, "framework": f"openai-agents {sdk}", "route": "LiteLLM bridge" if litellm_path else "OpenAI native", "endpoint": "/v1/chat/completions" if litellm_path else "/v1/responses", "reasoning_effort": "n/a (LiteLLM)" if litellm_path else REASONING_EFFORT, "api_key_env": "provider-specific" if litellm_path else "OPENAI_API_KEY", "api_key_present": ( True if litellm_path else bool(os.getenv("OPENAI_API_KEY")) ), "verdict": ( "OK — LiteLLM bridge. Non-OpenAI providers use chat-completions, which " "is fine: the tools/reasoning split is an OpenAI-specific constraint." if litellm_path else "OK — /v1/responses. Function tools and reasoning both supported." ), } # --------------------------------------------------------------------------- # Credentials # --------------------------------------------------------------------------- HF_TOKEN = os.getenv("HF_TOKEN") # Read from the environment (see .env, gitignored) — never hard-coded here, # because this file is tracked. The key currently in use is the one from # hmgill/organoid, which is committed in a public repo and should be treated as # already compromised. Rotate before the Meshy call goes live. MESHY_API_KEY = os.getenv("MESHY_API_KEY") def _flag(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} # Meshy costs credits per task. Off unless explicitly enabled; otherwise the tool # returns the request it *would* have made. # # This is the process-wide DEFAULT. A UI sets it per run on FossilContext, which # wins — a Space serves many users from one process, and a global would let one # person's switch spend another person's credits. MESHY_ENABLED = _flag("FOSSIL_MESHY_ENABLED", False) # For a fossil the surface texture usually IS the data — the ornament, the # fracture, the matrix. So it defaults ON. Turn it off for raw geometry # (morphometrics, 3D printing) or when you will retexture from calibrated photos. # # PBR maps are INFERRED by a generative model, not measured off the specimen. They # make renders look better; they are not data. Off by default, deliberately. MESHY_TEXTURE = _flag("FOSSIL_MESHY_TEXTURE", True) MESHY_PBR = _flag("FOSSIL_MESHY_PBR", False) MESHY_HD_TEXTURE = _flag("FOSSIL_MESHY_HD_TEXTURE", False) # --------------------------------------------------------------------------- # Background removal # --------------------------------------------------------------------------- # # "hf" — BRIA RMBG-2.0 via the HF Inference API. Needs HF_TOKEN. # "local" — GrabCut seeded by Lab chroma distance. No token, no network, no GPU. # This is what makes the pipeline testable in CI, and the reason the # whole thing degrades rather than dies without credentials. BG_BACKEND = os.getenv("FOSSIL_BG_BACKEND", "hf") # When the "hf" backend fails, should we fall back to offline GrabCut? # # On a laptop: yes — a worse matte beats a dead pipeline. # In a deployment that chose RMBG deliberately: NO. GrabCut produces a worse but # *plausible* matte, so the run still looks successful and the masks are merely a # bit off — which nobody notices until a mesh has been built on top of them. A # loud stop is cheaper than a quiet degradation. The Space sets this to 1. BG_STRICT = os.getenv("FOSSIL_BG_STRICT", "").lower() in {"1", "true", "yes", "on"} SAM3_CHECKPOINT = os.getenv("FOSSIL_SAM3_CHECKPOINT", "facebook/sam3") # --------------------------------------------------------------------------- # Keypoint selection # --------------------------------------------------------------------------- # # The AGENT selects the keypoints (fossil_agents/vision_analyst). Nothing here # picks a point. These only shape the aids it is given: # # GRID_STEP_TARGET — roughly how many gridlines across the canvas the agent # reads its coordinates from. More lines = finer ruler, # but a cluttered image the model reads worse. ~10 is the # sweet spot. # MAX_KEYPOINT_ROUNDS — how many times the agent may call check_keypoints_tool # and revise before it must commit. GRID_STEP_TARGET = int(os.getenv("FOSSIL_GRID_STEPS", "10")) MAX_KEYPOINT_ROUNDS = int(os.getenv("FOSSIL_MAX_KEYPOINT_ROUNDS", "3")) # --------------------------------------------------------------------------- # Geometric keypoint proposal — OFFLINE SMOKE-TEST PATH ONLY # --------------------------------------------------------------------------- # # helpers/geometry.py can propose points without a model. It is NOT in the agent # pipeline: it exists so `--dry-run` and the test suite can exercise steps 1-3 with # no API key. Do not reintroduce it into the agent path — point selection is the # agent's job. N_POSITIVE_POINTS = int(os.getenv("FOSSIL_N_POSITIVE", "4")) N_NEGATIVE_POINTS = int(os.getenv("FOSSIL_N_NEGATIVE", "6")) CORE_DT_FRACTION = float(os.getenv("FOSSIL_CORE_DT_FRACTION", "0.35")) # The model's *view* of an image is downscaled to this; tools always work at full # resolution on the original file. PREVIEW_MAX_SIDE = int(os.getenv("FOSSIL_PREVIEW_MAX_SIDE", "1024"))