diff --git a/.env.example b/.env.example index 94251d069..900940f94 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,14 @@ # OPENAI_API_KEY= # ANTHROPIC_API_KEY= +# Oracle Cloud Infrastructure Generative AI (litellm `oci/`; see docs/oci-generative-ai.md) +# OCI_COMPARTMENT_ID= # selects OCI in the quickstart examples +# OCI_REGION=us-chicago-1 +# OCI_CLI_PROFILE=DEFAULT # ~/.oci/config profile (needs `uv pip install oci`); or set the four variables below +# OCI_USER= +# OCI_TENANCY= +# OCI_FINGERPRINT= +# OCI_KEY_FILE=~/.oci/oci_api_key.pem + # Prevent litellm from fetching model costs from GitHub (uses bundled local data) LITELLM_LOCAL_MODEL_COST_MAP=True diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca9fd132..2e16abc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ to follow semantic versioning. ## [Unreleased] +- Add Oracle Cloud Infrastructure Generative AI as a documented provider: the + quickstart selector picks `oci/` models when `OCI_COMPARTMENT_ID` is set, signs + with an `~/.oci/config` profile via `OCI_CLI_PROFILE` or with `OCI_*` API-key + variables, and takes `OCI_MODEL` and `OCI_ENDPOINT_ID` overrides for imported models; new + `docs/oci-generative-ai.md` and `examples/quickstart/16_oci_generative_ai.py`. - Breaking: custom CodeAct error formatters must implement `format(error, code=None, *, line_offset=0, max_error=None, tail_chars=None)`. Reduced legacy signatures are no longer supported. diff --git a/README.md b/README.md index 8cc62adb1..21ea7df75 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ llm = get_llm_client("claude-haiku-4-5") llm = get_llm_client("gpt-5-mini") # OpenAI (after `export OPENAI_API_KEY=...`) llm = get_llm_client("ollama_chat/qwen3:1.7b", api_base="http://localhost:11434") # Ollama (no key) llm = get_llm_client("hosted_vllm/Qwen/Qwen3-1.7B", api_base="http://localhost:8000/v1") # vLLM (no key) +llm = get_llm_client("oci/meta.llama-3.3-70b-instruct", oci_region="us-chicago-1", oci_compartment_id="ocid1.compartment...") # OCI Generative AI (after `export OCI_*` credentials) ``` ### 2. Your first agent diff --git a/docs/README.md b/docs/README.md index 5412f00a2..a5da1d917 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,6 +66,7 @@ directory. For code you can run immediately, use the | [Tools and visibility](concepts/tools-and-visibility.md) | How does generated code discover and call capabilities? | | [Prompts and context](concepts/prompts-and-context.md) | Where should instructions, inputs, and cross-call information live? | | [Local models](local-models.md) | How do I run NOOA with local models? | +| [OCI Generative AI](oci-generative-ai.md) | How do I run NOOA on Oracle Cloud Infrastructure Generative AI, including imported Nemotron models? | | [Orchestration](concepts/orchestration.md) | How do I make a workflow deterministic without turning it into one giant prompt? | | [Multi-agent systems](concepts/multi-agent-systems.md) | When should I use another agent, and what state does it share? | | [Tracing](concepts/tracing.md) | How do I inspect the complete Python and LLM call tree? | diff --git a/docs/oci-generative-ai.md b/docs/oci-generative-ai.md new file mode 100644 index 000000000..af72cba19 --- /dev/null +++ b/docs/oci-generative-ai.md @@ -0,0 +1,187 @@ +# OCI Generative AI + +NOOA uses LiteLLM model strings through `get_llm_client()`, and LiteLLM routes +`oci/` to [Oracle Cloud Infrastructure (OCI) Generative AI](https://docs.oracle.com/en-us/iaas/Content/generative-ai/home.htm). +Any chat or text-generation model in the OCI Generative AI catalog, and any such +model you import into a dedicated endpoint, can drive a NOOA agent without +changing agent code. NOOA calls LiteLLM's completion interface, so the catalog's +embedding models are not used here. + +```python +from nooa.unifiedllm.registry import get_llm_client + +llm = get_llm_client( + "oci/meta.llama-3.3-70b-instruct", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1..example", +) +``` + +Requests go to `https://inference.generativeai..oci.oraclecloud.com`. +The region defaults to `us-ashburn-1`; set it to a region where your tenancy is +subscribed to the service. `oci_compartment_id` is required. + +## Authentication + +LiteLLM signs OCI requests itself. Two options: + +**API-key credentials.** Set the values from your OCI user's API key as +parameters or as environment variables, which LiteLLM reads automatically: + +```bash +export OCI_REGION=us-chicago-1 +export OCI_COMPARTMENT_ID=ocid1.compartment.oc1..example +export OCI_USER=ocid1.user.oc1..example +export OCI_TENANCY=ocid1.tenancy.oc1..example +export OCI_FINGERPRINT=aa:bb:cc:... +export OCI_KEY_FILE=~/.oci/oci_api_key.pem # or OCI_KEY with the PEM contents +``` + +**An OCI SDK signer.** If you already use the `oci` CLI, reuse a profile from +`~/.oci/config`, including session-token profiles created by +`oci session authenticate`, by passing a signer object: + +```python +import os + +import oci + +from nooa.unifiedllm.registry import get_llm_client + +config = oci.config.from_file(profile_name="DEFAULT") +if "security_token_file" in config: + with open(os.path.expanduser(config["security_token_file"])) as f: + token = f.read().strip() + private_key = oci.signer.load_private_key_from_file( + config["key_file"], pass_phrase=config.get("pass_phrase") + ) + signer = oci.auth.signers.SecurityTokenSigner(token, private_key) +else: + signer = oci.signer.Signer( + tenancy=config["tenancy"], + user=config["user"], + fingerprint=config["fingerprint"], + private_key_file_location=config["key_file"], + pass_phrase=config.get("pass_phrase"), + ) + +llm = get_llm_client( + "oci/meta.llama-3.3-70b-instruct", + oci_signer=signer, + oci_region=config.get("region", "us-ashburn-1"), + oci_compartment_id=os.environ["OCI_COMPARTMENT_ID"], +) +``` + +The `oci` SDK is not a NOOA dependency; install it with `uv pip install oci`. +The quickstart selector implements both options behind `OCI_CLI_PROFILE` and the +`OCI_*` variables; see [Quickstart selector](#quickstart-selector). + +## Choose a model + +Use the catalog model id after `oci/`. The default below is the one exercised +with NOOA's Predict and CodeAct strategies; the others are catalog examples that +LiteLLM routes the same way: + +| Model string | Notes | +| --- | --- | +| `oci/meta.llama-3.3-70b-instruct` | Default; tested with NOOA quickstarts 01, 02, 03, and 16 | +| `oci/meta.llama-4-maverick-17b-128e-instruct-fp8` | Multimodal | +| `oci/xai.grok-4` | Reasoning model | +| `oci/google.gemini-2.5-pro` | Reasoning model, multimodal | +| `oci/openai.gpt-oss-120b` | Open-weights MoE | +| `oci/cohere.command-a-03-2025` | Cohere family | + +Availability differs by region. List the catalog for your compartment with +`oci generative-ai model-collection list-models --compartment-id --region `. + +## NVIDIA Nemotron on OCI + +The Generative AI catalog does not include Nemotron models by default. Two ways to run them: + +**Imported model on a dedicated endpoint.** OCI Generative AI can +[import open-weights models](https://docs.oracle.com/en-us/iaas/Content/generative-ai/imported-models.htm), +including NVIDIA Nemotron 3 and Nemotron 3.5 Lightning, onto a dedicated AI +cluster behind an endpoint. Point LiteLLM at the endpoint: + +```python +llm = get_llm_client( + "oci/meta.llama-3.3-70b-instruct", # vendor prefix selects the request format; see below + oci_serving_mode="DEDICATED", + oci_endpoint_id="ocid1.generativeaiendpoint.oc1..example", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1..example", +) +``` + +With `oci_serving_mode="DEDICATED"` and an explicit `oci_endpoint_id`, the +endpoint decides which weights serve the request, but LiteLLM still uses the +model string's vendor prefix to choose the OCI request format and parameter +mapping: `cohere.*` selects the Cohere format, anything else the generic format. +Nemotron and other Llama-style imports use the generic format, so pass a +`meta.*` identifier such as the one above; for an imported Cohere model pass a +`cohere.*` identifier. + +**Self-hosted on OKE.** Serve Nemotron with vLLM on Oracle Container Engine for +Kubernetes and use LiteLLM's `hosted_vllm/` route, exactly as in +[Local models](local-models.md): + +```python +llm = get_llm_client( + "hosted_vllm/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4", + api_base="http://127.0.0.1:8000/v1", # e.g. a kubectl port-forward to the vLLM router +) +``` + +A reference deployment of Nemotron 3.5 Lightning on an OKE A10 node pool is in +[NVIDIA/nvidia-oci-samples](https://github.com/NVIDIA/nvidia-oci-samples). + +## Quickstart selector + +The quickstart examples pick a provider from your environment. When +`OCI_COMPARTMENT_ID` is set and `NVIDIA_API_KEY` is not, they use OCI Generative AI: + +| Variable | Effect | +| --- | --- | +| `OCI_COMPARTMENT_ID` | Selects OCI; passed as `oci_compartment_id` | +| `OCI_REGION` | Passed as `oci_region` (default `us-ashburn-1`) | +| `OCI_MODEL` | Model string, default `oci/meta.llama-3.3-70b-instruct` | +| `OCI_ENDPOINT_ID` | Adds `oci_serving_mode="DEDICATED"` and `oci_endpoint_id` | +| `OCI_CLI_PROFILE` | Signs requests with that `~/.oci/config` profile (API key or session token); needs `uv pip install oci` | +| `OCI_USER`, `OCI_TENANCY`, `OCI_FINGERPRINT`, `OCI_KEY_FILE` | API-key credentials read by LiteLLM when no profile is given | + +With these variables set, and `NVIDIA_API_KEY` unset, every quickstart in +`examples/quickstart/` runs on OCI Generative AI unchanged. `NVIDIA_API_KEY` +takes precedence over OCI in the selector. + +## Aliases + +Put repeated configuration in `.nooa/llm_config.yaml`: + +```yaml +models: + oci-llama: + model_name: oci/meta.llama-3.3-70b-instruct + temperature: 0.0 +``` + +Registry aliases forward `model_name`, `api_base`, `api_key_env`, and a fixed set +of generation parameters to LiteLLM. Provider settings such as the region and +compartment are not part of that set, so supply them through the `OCI_REGION` and +`OCI_COMPARTMENT_ID` environment variables or as call-site keyword arguments: + +```python +llm = get_llm_client("oci-llama") # OCI_* variables set +llm = get_llm_client("oci-llama", oci_compartment_id="ocid1....") # or override here +``` + +## Troubleshooting + +- `404` or `NotAuthorizedOrNotFound`: the model is not available in `oci_region`, + or the compartment lacks a policy allowing `generative-ai-family` access. +- `oci_compartment_id is required`: set `OCI_COMPARTMENT_ID` or pass the parameter. +- Signature errors with a session-token profile: the token expires after about an + hour; run `oci session authenticate` again and rebuild the signer. + +References: [LiteLLM OCI provider](https://docs.litellm.ai/docs/providers/oci), +[OCI Generative AI pretrained models](https://docs.oracle.com/en-us/iaas/Content/generative-ai/pretrained-models.htm). diff --git a/examples/README.md b/examples/README.md index 7f04664c1..9a1c6d9f2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,6 +45,7 @@ features. Each file is standalone and includes its exact run command. | 13 | [`13_multimodal.py`](quickstart/13_multimodal.py) | Image inputs with CodeAct and Predict | A vision-capable model | | 14 | [`14_atif_trajectory.py`](quickstart/14_atif_trajectory.py) | Exporting ATIF trajectories for evals and downstream tooling | — | | 15 | [`15_nemo_relay.py`](quickstart/15_nemo_relay.py) | NeMo Relay intercepts, guardrails, events, and nested generation | `uv sync --extra nemo-relay` | +| 16 | [`16_oci_generative_ai.py`](quickstart/16_oci_generative_ai.py) | OCI Generative AI as the provider through the quickstart selector: profile or API-key auth, dedicated endpoints | OCI credentials; see [OCI Generative AI](../docs/oci-generative-ai.md) | If you are new to NOOA, run examples 1–6 in order. After that, choose by the capability you need rather than treating the remaining files as required steps. diff --git a/examples/quickstart/16_oci_generative_ai.py b/examples/quickstart/16_oci_generative_ai.py new file mode 100644 index 000000000..c283e1dc2 --- /dev/null +++ b/examples/quickstart/16_oci_generative_ai.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# ruff: noqa: F403,F405 +"""Quickstart 16: Oracle Cloud Infrastructure (OCI) Generative AI as the model provider. + +NOOA is model-agnostic through litellm, which routes ``oci/`` to OCI +Generative AI. The quickstart model selector picks OCI when OCI_COMPARTMENT_ID is +set and NVIDIA_API_KEY is unset, so every quickstart in this directory runs on OCI +with the same variables: + + export OCI_COMPARTMENT_ID=ocid1.compartment.oc1..example + export OCI_REGION=us-chicago-1 # a region with the service + export OCI_CLI_PROFILE=DEFAULT # an ~/.oci/config profile (needs `uv pip install oci`) + # or, instead of a profile: OCI_USER, OCI_TENANCY, OCI_FINGERPRINT, OCI_KEY_FILE + uv run python examples/quickstart/16_oci_generative_ai.py + +Optional: OCI_MODEL (default oci/meta.llama-3.3-70b-instruct) and OCI_ENDPOINT_ID to +target a dedicated endpoint such as an imported NVIDIA Nemotron model. + +The agent is a capacity planner whose deterministic helpers are its only source +of facts. See docs/oci-generative-ai.md for explicit client construction, the +model catalog, aliases, and Nemotron options. +""" + +import sys + +from nooa.util.quickstart import * + +if not MODEL.startswith("oci/"): + print( + f"SKIP: the quickstart selector chose {MODEL!r}. Set OCI_COMPARTMENT_ID (and OCI credentials) to run on OCI." + ) + sys.exit(0) + +# GPU count and total GPU memory per OCI shape. The agent reads these through the +# helper methods instead of recalling specifications from training data. +GPU_SHAPES: dict[str, dict[str, float]] = { + "VM.GPU.A10.1": {"gpus": 1, "gpu_memory_gb": 24}, + "VM.GPU.A10.2": {"gpus": 2, "gpu_memory_gb": 48}, + "BM.GPU.A10.4": {"gpus": 4, "gpu_memory_gb": 96}, + "BM.GPU.A100-v2.8": {"gpus": 8, "gpu_memory_gb": 640}, + "BM.GPU.H100.8": {"gpus": 8, "gpu_memory_gb": 640}, +} + + +class Recommendation(BaseModel): + shape: str = Field(description="The recommended OCI GPU shape name.") + total_gpu_memory_gb: float = Field(description="Total GPU memory of that shape in GB.") + rationale: str = Field(description="One sentence explaining why this is the smallest fit.") + + +class CapacityPlanner(Agent, llm=llm): + """You plan GPU capacity on Oracle Cloud for serving open-weights models.""" + + def gpu_shapes(self) -> dict[str, dict[str, float]]: + """Return the available OCI GPU shapes with their GPU count and total GPU memory in GB.""" + return GPU_SHAPES + + def fits(self, weights_gb: float, shape: str, headroom: float = 1.25) -> bool: + """Whether model weights, with headroom for the KV cache, fit a shape's total GPU memory.""" + return weights_gb * headroom <= GPU_SHAPES[shape]["gpu_memory_gb"] + + async def recommend(self, model_name: str, weights_gb: float) -> Recommendation: + """Recommend the smallest shape whose GPU memory fits the model weights with headroom. + + Use self.gpu_shapes() and self.fits() rather than recalling shape specifications. + """ + ... + + +@autorun +async def main(): + planner = CapacityPlanner() + result = await planner.recommend( + "NVIDIA Nemotron 3.5 Lightning 30B-A3B (NVFP4)", weights_gb=21.6 + ) + print(f"model: {MODEL}") + print(result) diff --git a/src/nooa/util/quickstart.py b/src/nooa/util/quickstart.py index 24ab731c2..930004978 100644 --- a/src/nooa/util/quickstart.py +++ b/src/nooa/util/quickstart.py @@ -19,11 +19,48 @@ # Load environment variables load_dotenv(override=True) + +def _oci_signer_from_profile(profile: str) -> Any: + """Build an OCI SDK request signer from an ``~/.oci/config`` profile. + + Supports API-key profiles and the session-token profiles that + ``oci session authenticate`` writes. litellm signs OCI Generative AI + requests with the returned object when it is passed as ``oci_signer``. + """ + try: + import oci + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "OCI_CLI_PROFILE is set but the OCI Python SDK is not installed. " + "Install it with `uv pip install oci`, or set OCI_USER, OCI_TENANCY, " + "OCI_FINGERPRINT, and OCI_KEY_FILE instead." + ) from exc + + config = oci.config.from_file(profile_name=profile) + if "security_token_file" in config: + with open(os.path.expanduser(config["security_token_file"])) as f: + token = f.read().strip() + # Profiles made with `oci session authenticate --use-passphrase` store the passphrase too. + private_key = oci.signer.load_private_key_from_file( + config["key_file"], pass_phrase=config.get("pass_phrase") + ) + return oci.auth.signers.SecurityTokenSigner(token, private_key) + return oci.signer.Signer( + tenancy=config["tenancy"], + user=config["user"], + fingerprint=config["fingerprint"], + private_key_file_location=config["key_file"], + pass_phrase=config.get("pass_phrase"), + ) + + # The examples run against any litellm-supported provider. By default they pick # whichever credential you have set (see the README's "API Keys"): # * NVIDIA_API_KEY -> NVIDIA build.nvidia.com NIM (public), served at # integrate.api.nvidia.com (litellm `nvidia_nim/`) # * OPENAI_API_KEY -> OpenAI (public) +# * OCI_COMPARTMENT_ID -> Oracle Cloud Infrastructure Generative AI +# (litellm `oci/`; see docs/oci-generative-ai.md) # * NVIDIA_INFERENCE_API_KEY -> NVIDIA internal inference gateway # (inference-api.nvidia.com; NVIDIA employees) # To use a specific model, set MODEL to any litellm name and provide its key, @@ -35,6 +72,25 @@ # pass NVIDIA_API_KEY (the build.nvidia.com convention) explicitly. MODEL = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" llm = get_llm_client(MODEL, api_key=os.environ["NVIDIA_API_KEY"]) +elif os.getenv("OCI_COMPARTMENT_ID"): + # Oracle Cloud Infrastructure (OCI) Generative AI. litellm routes `oci/*` to + # inference.generativeai..oci.oraclecloud.com and reads API-key + # credentials from OCI_USER, OCI_FINGERPRINT, OCI_TENANCY, and OCI_KEY_FILE + # (or OCI_KEY), or from the ~/.oci/config profile named by OCI_CLI_PROFILE. + # OCI_MODEL picks another catalog model; OCI_ENDPOINT_ID targets a dedicated + # endpoint such as an imported NVIDIA Nemotron model. See docs/oci-generative-ai.md. + MODEL = os.getenv("OCI_MODEL", "oci/meta.llama-3.3-70b-instruct") + _oci_kwargs: dict[str, Any] = {"oci_compartment_id": os.environ["OCI_COMPARTMENT_ID"]} + if os.getenv("OCI_REGION"): + _oci_kwargs["oci_region"] = os.environ["OCI_REGION"] + if os.getenv("OCI_ENDPOINT_ID"): + _oci_kwargs["oci_serving_mode"] = "DEDICATED" + _oci_kwargs["oci_endpoint_id"] = os.environ["OCI_ENDPOINT_ID"] + if os.getenv("OCI_CLI_PROFILE"): + # Reuse an ~/.oci/config profile (API key or `oci session authenticate` + # token) instead of OCI_* credential variables. Needs the `oci` SDK. + _oci_kwargs["oci_signer"] = _oci_signer_from_profile(os.environ["OCI_CLI_PROFILE"]) + llm = get_llm_client(MODEL, **_oci_kwargs) elif os.getenv("OPENAI_API_KEY"): MODEL = "gpt-5-mini" llm = get_llm_client(MODEL) diff --git a/tests/unit/test_quickstart_oci_selection.py b/tests/unit/test_quickstart_oci_selection.py new file mode 100644 index 000000000..47d4ac147 --- /dev/null +++ b/tests/unit/test_quickstart_oci_selection.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Quickstart model selector: the OCI Generative AI branch.""" + +import importlib +import os +from unittest.mock import patch + +import pytest + + +def _reload_quickstart(env: dict[str, str]): + """Re-import ``nooa.util.quickstart`` under a controlled environment. + + ``get_llm_client`` is patched so no client is constructed; the mock records + the model string and provider kwargs the selector chose. + """ + with ( + patch.dict(os.environ, env, clear=True), + patch("dotenv.load_dotenv"), + patch("nooa.unifiedllm.registry.get_llm_client") as get_llm_client, + ): + import nooa.util.quickstart as quickstart + + importlib.reload(quickstart) + return quickstart.MODEL, get_llm_client + + +@pytest.fixture(autouse=True) +def _restore_quickstart(): + yield + _reload_quickstart({}) + + +def test_oci_compartment_selects_oci_generative_ai() -> None: + model, get_llm_client = _reload_quickstart( + {"OCI_COMPARTMENT_ID": "ocid1.compartment.oc1..example", "OCI_REGION": "us-chicago-1"} + ) + + assert model == "oci/meta.llama-3.3-70b-instruct" + get_llm_client.assert_called_with( + "oci/meta.llama-3.3-70b-instruct", + oci_compartment_id="ocid1.compartment.oc1..example", + oci_region="us-chicago-1", + ) + + +def test_oci_model_and_dedicated_endpoint_overrides() -> None: + model, get_llm_client = _reload_quickstart( + { + "OCI_COMPARTMENT_ID": "ocid1.compartment.oc1..example", + "OCI_MODEL": "oci/my-imported-nemotron", + "OCI_ENDPOINT_ID": "ocid1.generativeaiendpoint.oc1..example", + } + ) + + assert model == "oci/my-imported-nemotron" + get_llm_client.assert_called_with( + "oci/my-imported-nemotron", + oci_compartment_id="ocid1.compartment.oc1..example", + oci_serving_mode="DEDICATED", + oci_endpoint_id="ocid1.generativeaiendpoint.oc1..example", + ) + + +def test_nvidia_key_takes_precedence_over_oci() -> None: + model, _ = _reload_quickstart( + {"NVIDIA_API_KEY": "nvapi-example", "OCI_COMPARTMENT_ID": "ocid1.compartment.oc1..example"} + ) + + assert model.startswith("nvidia_nim/") + + +def test_oci_takes_precedence_over_openai_key() -> None: + model, _ = _reload_quickstart( + {"OPENAI_API_KEY": "sk-example", "OCI_COMPARTMENT_ID": "ocid1.compartment.oc1..example"} + ) + + assert model.startswith("oci/") + + +def test_oci_cli_profile_builds_a_signer_without_credential_variables() -> None: + import sys + from types import SimpleNamespace + from unittest.mock import MagicMock + + fake_oci = MagicMock() + fake_oci.config.from_file.return_value = { + "security_token_file": "/tmp/nooa-test-token", + "key_file": "/tmp/nooa-test-key.pem", + } + fake_oci.auth.signers.SecurityTokenSigner.return_value = SimpleNamespace(kind="session-token") + + with ( + patch.dict(sys.modules, {"oci": fake_oci}), + patch("builtins.open", create=True) as open_mock, + ): + open_mock.return_value.__enter__.return_value.read.return_value = "token-value\n" + model, get_llm_client = _reload_quickstart( + { + "OCI_COMPARTMENT_ID": "ocid1.compartment.oc1..example", + "OCI_CLI_PROFILE": "DEFAULT", + } + ) + + assert model == "oci/meta.llama-3.3-70b-instruct" + fake_oci.config.from_file.assert_called_once_with(profile_name="DEFAULT") + kwargs = get_llm_client.call_args.kwargs + assert kwargs["oci_compartment_id"] == "ocid1.compartment.oc1..example" + assert kwargs["oci_signer"].kind == "session-token" + + +def test_oci_cli_profile_without_sdk_raises_a_clear_error() -> None: + import sys + + with ( + patch.dict(sys.modules, {"oci": None}), + pytest.raises(ModuleNotFoundError, match="uv pip install oci"), + ): + _reload_quickstart( + {"OCI_COMPARTMENT_ID": "ocid1.compartment.oc1..example", "OCI_CLI_PROFILE": "DEFAULT"} + )